├── .coveralls.yml ├── .eslintrc ├── .gitignore ├── .npmignore ├── .prettierrc ├── .travis.yml ├── .vscode └── settings.json ├── README.md ├── docs.sh ├── package.json ├── src ├── caveat.ts ├── helpers.ts ├── identifier.ts ├── index.ts ├── lsat.ts ├── macaroon.ts ├── satisfiers.ts ├── service.ts └── types │ ├── index.ts │ ├── lsat.ts │ ├── modules.d.ts │ └── satisfier.ts ├── tests ├── .coveralls.yml ├── .travis.yml ├── caveat.spec.ts ├── data.ts ├── helpers.spec.ts ├── identifier.spec.ts ├── lsat.spec.ts ├── macaroon.spec.ts ├── satisfiers.spec.ts ├── service.spec.ts └── utilities.ts ├── tsconfig.base.json ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-pro 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "es6": true 6 | }, 7 | "plugins": ["@typescript-eslint", "prettier"], 8 | "parser": "@typescript-eslint/parser", 9 | "parserOptions": { 10 | "ecmaVersion": 2018, 11 | "sourceType": "module", 12 | "project": ["./tsconfig.json"] 13 | }, 14 | "rules": { 15 | "class-methods-use-this": 0, 16 | "import/no-named-as-default": 0, 17 | "require-atomic-updates": "warn", 18 | "no-unused-vars": ["warn", { "varsIgnorePattern": "^_" }], 19 | "@typescript-eslint/no-unused-vars": [ 20 | "warn", 21 | { "varsIgnorePattern": "^_" } 22 | ], 23 | "@typescript-eslint/no-var-requires": 0, 24 | "@typescript-eslint/naming-convention": [ 25 | "error", 26 | { 27 | "selector": "default", 28 | "format": ["camelCase"], 29 | "leadingUnderscore": "allow" 30 | }, 31 | 32 | { 33 | "selector": "variable", 34 | "format": ["camelCase", "UPPER_CASE"] 35 | }, 36 | { 37 | "selector": "parameter", 38 | "format": ["camelCase"], 39 | "leadingUnderscore": "allow" 40 | }, 41 | 42 | { 43 | "selector": "memberLike", 44 | "modifiers": ["private"], 45 | "format": ["camelCase"], 46 | "leadingUnderscore": "require" 47 | }, 48 | 49 | { 50 | "selector": "typeLike", 51 | "format": ["PascalCase"] 52 | }, 53 | 54 | { 55 | "selector": "variable", 56 | "format": ["PascalCase"], 57 | "filter": { 58 | "regex": "TextEncoder", 59 | "match": true 60 | } 61 | } 62 | ], 63 | "no-console": "error" 64 | }, 65 | "extends": [ 66 | "eslint:recommended", 67 | "plugin:@typescript-eslint/recommended", 68 | "plugin:@typescript-eslint/eslint-recommended", 69 | "prettier", 70 | "prettier/@typescript-eslint" 71 | ] 72 | } 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | *.env* 11 | 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | 18 | # nyc test coverage 19 | .nyc_output 20 | 21 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 22 | .grunt 23 | 24 | # node-waf configuration 25 | .lock-wscript 26 | 27 | # Compiled binary addons (http://nodejs.org/api/addons.html) 28 | build/Release 29 | dist 30 | public/build 31 | docs 32 | 33 | # Dependency directories 34 | node_modules 35 | jspm_packages 36 | 37 | # Optional npm cache directory 38 | .npm 39 | 40 | # Optional REPL history 41 | .node_repl_history 42 | 43 | # Custom 44 | .DS_Store 45 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | docs/ 2 | *.env* -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "printWidth": 80, 7 | "camelcase": "never" 8 | } 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "12" 4 | cache: yarn 5 | script: 6 | - yarn lint 7 | - yarn build 8 | - yarn test 9 | after_success: yarn run coverage 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "html.format.unformatted": "", 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "[javascript]": { 5 | "editor.defaultFormatter": "esbenp.prettier-vscode" 6 | }, 7 | "[typescript]": { 8 | "editor.defaultFormatter": "esbenp.prettier-vscode" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) 2 | [![Build Status](https://travis-ci.com/Tierion/lsat-js.svg?branch=master)](https://travis-ci.com/Tierion/lsat-js) 3 | [![Coverage Status](https://coveralls.io/repos/github/Tierion/lsat-js/badge.svg)](https://coveralls.io/github/Tierion/lsat-js) 4 | 5 | # LSAT-JS 6 | 7 | `lsat-js` is a suite of tools for working with LSATs in Nodejs and Typescript. 8 | 9 | An LSAT, or Lightning Service Authentication Token, is a "Lightning native macaroon based bearer 10 | API credential" [1](https://docs.google.com/presentation/d/1QSm8tQs35-ZGf7a7a2pvFlSduH3mzvMgQaf-06Jjaow/edit#slide=id.g623e4b6d0b_0_32). 11 | In other words, a new authentication specification that supports account-less authentication 12 | using lightning invoices and payments. 13 | 14 | ## Installation 15 | 16 | First install as a module using npm or yarn into your package: 17 | 18 | ```bash 19 | $> npm --save install lsat-js 20 | # or 21 | $> yarn add lsat-js 22 | ``` 23 | 24 | ## Usage 25 | 26 | `lsat-js` is not a stand-alone server or client and so it can't issue or generate Lsats on its own. It simply 27 | offers utlities for working with Lsats including: 28 | 29 | - Serialization and deserialization 30 | - Validation 31 | - Working with caveats (restrictions placed on the LSAT's macaroon) 32 | 33 | To test with an actual server that issues LSATs, check out [boltwall](https://github.com/Tierion/boltwall). 34 | The below example assumes a running boltwall (or compatible) server on your local machine. 35 | The same utilities can be used against raw LSATs as well. 36 | 37 | ```js 38 | import { Lsat } from 'lsat-js' 39 | import fetch from 'node-fetch' 40 | 41 | // fetching a protected route which will return a 402 response and LSAT challenge 42 | fetch('http://localhost:5000/protected') 43 | .then(resp => { 44 | const header = resp.headers.get('www-authenticate') 45 | const lsat = Lsat.fromHeader(header) 46 | 47 | // show some information about the lsat 48 | console.log(lsat.invoice) 49 | console.log(lsat.baseMacaroon) 50 | console.log(lsat.paymentHash) 51 | 52 | // after the invoice is paid, you can add the preimage 53 | // this is just a stub for getting the preimage string 54 | const preimage = getPreimage() 55 | 56 | // this will validate that the preimage is valid and throw if not 57 | lsat.setPreimage(preimage) 58 | 59 | return fetch('http://localhost:5000/protected', { 60 | headers: { 61 | 'Authorization': lsat.toToken() 62 | } 63 | }) 64 | }) 65 | .then(resp => resp.json()) 66 | .then(json => { 67 | console.log('With valid LSAT, we should get a response:', json) 68 | }) 69 | .catch(e => console.error(e)) 70 | ``` 71 | 72 | ## API 73 | 74 | To view detailed API docs and type information, please see our full 75 | [API documentation](https://tierion.github.io/lsat-js/). 76 | 77 | `lsat-js` provides the following utilities for working with LSATs: 78 | 79 | #### Lsat 80 | 81 | A class for serializing and deserializing an LSAT. It supports: 82 | 83 | - Getting an LSAT from a response header 84 | - Getting an LSAT the raw challenge (header without the `LSAT` type prefix) 85 | - Serializing and Deserializing from a "token" (i.e. what the client sends in the `Authorization` header) 86 | - Adding and verifying the preimage (it will throw if the preimage is not properly formatted 87 | or if it does not match the invoice's payment hash) 88 | - Checking if the macaroon is expired 89 | - Versioning through the Identifier class (also exposed via `lsat-js`) to support future updates 90 | to LSAT serialization 91 | - Adding new first party caveats 92 | - Listing all caveats on an LSAT's macaroon 93 | 94 | #### Caveat 95 | 96 | A caveat is a "condition" that is placed on a macaroon that restricts its use. Using these, 97 | an LSAT can contain additional authorization restrictions besides just payment, e.g. time based or user 98 | based restrictions or authorization levels. This also allows for "attenuation", i.e. the holder of the 99 | LSAT can lend out the authorization with additional caveats restricting its use. 100 | 101 | Creating a caveat is as simple as: 102 | 103 | ```js 104 | import { Caveat } from 'lsat-js' 105 | 106 | const caveat = new Caveat({ 107 | condition: 'expiration', 108 | value: Date.now() + 10000, // expires in 10 seconds 109 | comp: '=', // this is the default value, also supports "<" and ">" 110 | }) 111 | console.log(caveat.encode()) // returns `expiration=1577228778197` 112 | console.log(Caveat.decode(caveat.encode())) // creates new caveat w/ same properties 113 | ``` 114 | 115 | To add the caveat to a macaroon you'll need to use a compatible macaroon library 116 | such as [macaroon.js](https://github.com/nitram509/macaroons.js), or add it to an LSAT's 117 | macaroon with the `addFirstPartyCaveat` method available on the `Lsat` object. 118 | 119 | #### `hasCaveat` 120 | 121 | A function that takes a raw macaroon and a caveat and returns true or false depending on if 122 | the macaroon contains that caveat. 123 | 124 | #### `verifyCaveats` 125 | 126 | Verifies caveats given one or a set of caveats and corresponding "satisfiers" to test the caveats against. 127 | A satisfier is an object with a condition, a `satisfyFinal` function, and an optional `satisfyPrevious` 128 | function. `satisfyFinal` will test only the last caveat on a macaroon of the matching condition 129 | and `satisfyPrevious` compares each caveat of the same condition against each other. This allows 130 | more flexible attenuation where you can ensure, for example, that every "new" caveat is not less 131 | restrictive than a previously added one. In the case of an expiration, you probably want to have a satisfier 132 | that tests that a newer `expiration` is sooner than the first `expiration` added, otherwise, a client could 133 | add their own expiration further into the future. 134 | 135 | The exported `Satisfier` interface described in the docs provides more details on creating 136 | your own satisfiers 137 | 138 | #### `verifyMacaroonCaveats` 139 | 140 | This can only be run by the creator of the macaroon since the signing secret is required to 141 | verify the macaroon. This will run all necessary checks (requires satisfiers to be passed 142 | as arguments if there are caveats on the macaroon) and return true if the macaroon is valid 143 | or false otherwise. 144 | -------------------------------------------------------------------------------- /docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | directory=docs 3 | tempDir=_docs 4 | branch=gh-pages 5 | 6 | build_command() { 7 | mkdir $tempDir 8 | # generate the typedocs 9 | typedoc --name LSAT-JS --readme ./README.md --out $tempDir --tsconfig tsconfig.json src/ 10 | # add nojekyll so github pages builds correctly 11 | touch "$directory/.nojekyll" 12 | # move typedocs into deploying directory 13 | mv $tempDir/* $directory 14 | # remove temporary directory 15 | rm -rf $tempDir 16 | } 17 | 18 | echo -e "\033[0;32mDeleting old content...\033[0m" 19 | rm -rf $directory 20 | 21 | echo -e "\033[0;32mChecking out $branch....\033[0m" 22 | git worktree add $directory $branch 23 | 24 | echo -e "\033[0;32mGenerating site...\033[0m" 25 | build_command 26 | 27 | echo -e "\033[0;32mDeploying $branch branch...\033[0m" 28 | cd $directory && 29 | git add --all && 30 | git commit -m "Deploy updates" && 31 | git push origin $branch 32 | 33 | echo -e "\033[0;32mCleaning up...\033[0m" 34 | git worktree remove $directory 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lsat-js", 3 | "version": "2.0.6", 4 | "description": "Utility library for working with LSAT auth tokens in javascript", 5 | "main": "./dist/index.js", 6 | "repository": "https://github.com/Tierion/lsat-js", 7 | "author": "Buck Perley", 8 | "license": "MIT", 9 | "private": false, 10 | "types": "./dist/index.d.ts", 11 | "scripts": { 12 | "build": "tsc --project tsconfig.build.json", 13 | "coverage": "nyc report --reporter=text-lcov | coveralls", 14 | "clean": "rm -rf dist && mkdir dist", 15 | "lint": "tsc --noEmit --project tsconfig.json && eslint --ext .ts src --quiet", 16 | "test": "nyc ts-mocha -p tsconfig.json tests/**/*.spec.ts", 17 | "test:watch": "ts-mocha -p tsconfig.json --reporter spec --watch --watch-extensions ts tests/**/*.spec.ts", 18 | "docs": "./docs.sh", 19 | "prepare": "npm run clean && npm run build", 20 | "prepublishOnly": "npm run clean && npm run build && npm run docs", 21 | "postversion": "git push && git push --tags" 22 | }, 23 | "dependencies": { 24 | "@stablelib/base64": "^1.0.1", 25 | "@types/node": "^13.1.0", 26 | "@types/sjcl": "^1.0.29", 27 | "@types/uuid": "^3.4.6", 28 | "bolt11": "^1.3.2", 29 | "bsert": "^0.0.10", 30 | "bufio": "^1.0.6", 31 | "macaroon": "git+https://github.com/tierion/js-macaroon.git#ts", 32 | "uuid": "^3.3.3" 33 | }, 34 | "devDependencies": { 35 | "@types/chai": "^4.2.5", 36 | "@types/mocha": "^5.2.7", 37 | "@types/sinon": "^7.5.1", 38 | "@typescript-eslint/eslint-plugin": "^5.11.0", 39 | "@typescript-eslint/parser": "^2.9.0", 40 | "chai": "^4.2.0", 41 | "coveralls": "^3.0.9", 42 | "eslint": "^8.8.0", 43 | "eslint-config-prettier": "6.0.0", 44 | "eslint-plugin-prettier": "3.1.0", 45 | "mocha": "^9.2.0", 46 | "nyc": "^15.0.0", 47 | "prettier": "1.18.2", 48 | "sinon": "^7.5.0", 49 | "ts-mocha": "^6.0.0", 50 | "tslint": "^5.20.1", 51 | "typedoc": "^0.22.11", 52 | "typescript": "^4.5.5" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/caveat.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Provides utilities for managing, analyzing, and validating caveats 3 | * @author Buck Perley 4 | */ 5 | import assert from 'bsert' 6 | import { CaveatOptions, Satisfier } from './types' 7 | 8 | import * as Macaroon from 'macaroon' 9 | import { MacaroonJSONV2 } from 'macaroon/src/macaroon' 10 | 11 | /** 12 | * @description Creates a new error describing a problem with creating a new caveat 13 | * @extends Error 14 | */ 15 | export class ErrInvalidCaveat extends Error { 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | constructor(...params: any[]) { 18 | // Pass remaining arguments (including vendor specific ones) to parent constructor 19 | super(...params) 20 | 21 | // Maintains proper stack trace for where our error was thrown (only available on V8) 22 | if (Error.captureStackTrace) { 23 | Error.captureStackTrace(this, ErrInvalidCaveat) 24 | } 25 | 26 | this.name = 'ErrInvalidCaveat' 27 | // Custom debugging information 28 | this.message = `Caveat must be of the form "condition[<,=,>]value"` 29 | } 30 | } 31 | 32 | const validComp = new Set(['<', '>', '=']) 33 | 34 | /** 35 | * @typedef {Object} Caveat 36 | * @description A caveat is a class with a condition, value and a comparator. They 37 | * are used in macaroons to evaluate the validity of a macaroon. The Caveat class 38 | * provides a method for turning a string into a caveat object (decode) and a way to 39 | * turn a caveat into a string that can be encoded into a macaroon. 40 | */ 41 | export class Caveat { 42 | condition: string 43 | value: string | number 44 | comp: string 45 | 46 | /** 47 | * Create a caveat 48 | * @param {Object} options - options to create a caveat from 49 | * @param {string} options.condition - condition that will be evaluated, e.g. "expiration", "ip", etc. 50 | * @param {string} options.value - the value that the caveat should equal. When added to a macaroon this is what 51 | * the request is evaluated against. 52 | * @param {string} [comp="="] - one of "=", "<", ">" which describes how the value is compared. So "time<1576799124987" 53 | * would mean we will evaluate a time that is less than "1576799124987" 54 | */ 55 | constructor(options: CaveatOptions) { 56 | this.condition = '' 57 | this.value = '' 58 | this.comp = '=' 59 | 60 | if (options) this.fromOptions(options) 61 | } 62 | 63 | fromOptions(options: CaveatOptions): this { 64 | assert(options, 'Data required to create new caveat') 65 | 66 | assert( 67 | typeof options.condition === 'string' && options.condition.length, 68 | 'Require a condition' 69 | ) 70 | this.condition = options.condition 71 | 72 | options.value.toString() 73 | this.value = options.value 74 | 75 | if (options.comp) { 76 | if (!validComp.has(options.comp)) throw new ErrInvalidCaveat() 77 | this.comp = options.comp 78 | } 79 | 80 | return this 81 | } 82 | 83 | /** 84 | * @returns {string} Caveat as string value. e.g. `expiration=1576799124987` 85 | */ 86 | encode(): string { 87 | return `${this.condition}${this.comp}${this.value}` 88 | } 89 | 90 | /** 91 | * 92 | * @param {string} c - create a new caveat from a string 93 | * @returns {Caveat} 94 | */ 95 | static decode(c: string): Caveat { 96 | let compIndex 97 | for (let i = 0; i < c.length; i++) { 98 | if (validComp.has(c[i])) { 99 | compIndex = i 100 | break 101 | } 102 | } 103 | if (!compIndex) throw new ErrInvalidCaveat() 104 | 105 | const condition = c.slice(0, compIndex).trim() 106 | const comp = c[compIndex].trim() 107 | const value = c.slice(compIndex + 1).trim() 108 | 109 | return new this({ condition, comp, value }) 110 | } 111 | } 112 | 113 | /** 114 | * @description hasCaveat will take a macaroon and a caveat and evaluate whether or not 115 | * that caveat exists on the macaroon 116 | * @param {string} rawMac - raw macaroon to determine caveats from 117 | * @param {Caveat|string} c - Caveat to test against macaroon 118 | * @returns {boolean} 119 | */ 120 | export function hasCaveat( 121 | rawMac: string, 122 | c: Caveat | string 123 | ): string | boolean | ErrInvalidCaveat { 124 | const macaroon = Macaroon.importMacaroon(rawMac)._exportAsJSONObjectV2() 125 | let caveat: Caveat 126 | if (typeof c === 'string') caveat = Caveat.decode(c) 127 | else caveat = c 128 | 129 | const condition = caveat.condition 130 | if (macaroon.c == undefined) { 131 | return false 132 | } 133 | let value 134 | macaroon.c.forEach((packet: MacaroonJSONV2.Caveat) => { 135 | try { 136 | if (packet.i != undefined) { 137 | const test = Caveat.decode(packet.i) 138 | if (condition === test.condition) value = test.value 139 | } 140 | } catch (e) { 141 | // ignore if caveat is unable to be decoded since we don't know it anyway 142 | } 143 | }) 144 | if (value) return value 145 | return false 146 | } 147 | 148 | /** 149 | * @description A function that verifies the caveats on a macaroon. 150 | * The functionality mimics that of loop's lsat utilities. 151 | * @param caveats a list of caveats to verify 152 | * @param {Satisfier} satisfiers a single satisfier or list of satisfiers used to verify caveats 153 | * @param {Object} [options] An optional options object that will be passed to the satisfiers. 154 | * In many circumstances this will be a request object, for example when this is used in a server 155 | * @returns {boolean} 156 | */ 157 | export function verifyCaveats( 158 | caveats: Caveat[], 159 | satisfiers?: Satisfier | Satisfier[], 160 | options: object = {} 161 | ): boolean { 162 | // if there are no satisfiers then we can just assume everything is verified 163 | if (!satisfiers) return true 164 | else if (!Array.isArray(satisfiers)) satisfiers = [satisfiers] 165 | 166 | // create map of satisfiers keyed by their conditions 167 | const caveatSatisfiers = new Map() 168 | 169 | for (const satisfier of satisfiers) { 170 | caveatSatisfiers.set(satisfier.condition, satisfier) 171 | } 172 | 173 | // create a map of relevant caveats to satisfiers keyed by condition 174 | // with an array of caveats for each condition 175 | const relevantCaveats = new Map() 176 | 177 | for (const caveat of caveats) { 178 | // skip if condition is not in our satisfier map 179 | const condition = caveat.condition 180 | if (!caveatSatisfiers.has(condition)) continue 181 | 182 | if (!relevantCaveats.has(condition)) relevantCaveats.set(condition, []) 183 | const caveatArray = relevantCaveats.get(condition) 184 | caveatArray.push(caveat) 185 | relevantCaveats.set(condition, caveatArray) 186 | } 187 | 188 | // for each condition in the caveat map 189 | for (const [condition, caveatsList] of relevantCaveats) { 190 | // get the satisifer for that condition 191 | const satisfier = caveatSatisfiers.get(condition) 192 | 193 | // loop through the array of caveats 194 | for (let i = 0; i < caveatsList.length - 1; i++) { 195 | // confirm satisfyPrevious 196 | const prevCaveat = caveatsList[i] 197 | const curCaveat = caveatsList[i + 1] 198 | if (!satisfier.satisfyPrevious(prevCaveat, curCaveat, options)) 199 | return false 200 | } 201 | 202 | // check satisfyFinal for the final caveat 203 | if (!satisfier.satisfyFinal(caveatsList[caveatsList.length - 1], options)) 204 | return false 205 | } 206 | return true 207 | } 208 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import bolt11 from 'bolt11' 2 | import assert from 'bsert' 3 | import { MacaroonClass } from './types'; 4 | import * as Macaroon from 'macaroon' 5 | 6 | let TextEncoder 7 | if (typeof window !== 'undefined' && window && window.TextEncoder) { 8 | TextEncoder = window.TextEncoder; 9 | } else { 10 | // No window.TextEncoder if it's node.js. 11 | const util = require('util'); 12 | TextEncoder = util.TextEncoder; 13 | } 14 | 15 | export const utf8Encoder = new TextEncoder(); 16 | export const isValue = (x: string | null | undefined): boolean => x !== undefined && x !== null; 17 | export const stringToBytes = (s: string | null | undefined): Uint8Array => isValue(s) ? utf8Encoder.encode(s) : s; 18 | 19 | 20 | /** 21 | * @description Given a string, determine if it is in hex encoding or not. 22 | * @param {string} h - string to evaluate 23 | */ 24 | export function isHex(h: string): boolean { 25 | return Buffer.from(h, 'hex').toString('hex') === h 26 | } 27 | 28 | // A wrapper around bolt11's decode to handle 29 | // simnet invoices 30 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 31 | export function decode(req: string): any { 32 | let network 33 | if (req.indexOf('lnsb') === 0) 34 | network = { bech32: 'sb'} 35 | return bolt11.decode(req, network) 36 | } 37 | 38 | export function getIdFromRequest(req: string): string { 39 | const request = decode(req) 40 | type Tag = {tagName: string, data?: string} 41 | const hashTag = request.tags.find((tag: Tag) => tag.tagName === 'payment_hash') 42 | assert(hashTag && hashTag.data, 'Could not find payment hash on invoice request') 43 | const paymentHash = hashTag?.data.toString() 44 | 45 | if (!paymentHash || !paymentHash.length) 46 | throw new Error('Could not get payment hash from payment request') 47 | 48 | return paymentHash 49 | } 50 | -------------------------------------------------------------------------------- /src/identifier.ts: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const bufio = require('bufio') 3 | import crypto from 'crypto' 4 | import uuidv4 from 'uuid/v4' 5 | import * as Macaroon from 'macaroon' 6 | 7 | import { IdentifierOptions } from './types' 8 | 9 | export const LATEST_VERSION = 0 10 | export const TOKEN_ID_SIZE = 32 11 | 12 | export class ErrUnknownVersion extends Error { 13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 | constructor(version: number | string, ...params: any[]) { 15 | // Pass remaining arguments (including vendor specific ones) to parent constructor 16 | super(...params) 17 | 18 | // Maintains proper stack trace for where our error was thrown (only available on V8) 19 | if (Error.captureStackTrace) { 20 | Error.captureStackTrace(this, ErrUnknownVersion) 21 | } 22 | 23 | this.name = 'ErrUnknownVersion' 24 | // Custom debugging information 25 | this.message = `${this.name}:${version}` 26 | } 27 | } 28 | 29 | /** 30 | * @description An identifier encodes information about our LSAT that can be used as a unique identifier 31 | * and is used to generate a macaroon. 32 | * @extends Struct 33 | */ 34 | export class Identifier extends bufio.Struct { 35 | /** 36 | * 37 | * @param {Object} options - options to create a new Identifier 38 | * @param {number} version - version of the identifier used to determine encoding of the raw bytes 39 | * @param {Buffer} paymentHash - paymentHash of the invoice associated with the LSAT. 40 | * @param {Buffer} tokenId - random 32-byte id used to identify the LSAT by 41 | */ 42 | constructor(options: IdentifierOptions | void) { 43 | super(options) 44 | 45 | this.version = LATEST_VERSION 46 | this.paymentHash = null 47 | this.tokenId = null 48 | 49 | if (options) this.fromOptions(options) 50 | } 51 | 52 | fromOptions(options: IdentifierOptions): this { 53 | if (options.version && options.version > LATEST_VERSION) 54 | throw new ErrUnknownVersion(options.version) 55 | else if (options.version) this.version = options.version 56 | 57 | assert( 58 | typeof this.version === 'number', 59 | 'Identifier version must be a number' 60 | ) 61 | 62 | assert( 63 | options.paymentHash.length === 32, 64 | `Expected 32-byte hash, instead got ${options.paymentHash.length}` 65 | ) 66 | this.paymentHash = options.paymentHash 67 | 68 | // TODO: generate random uuidv4 id (and hash to 32 to match length) 69 | if (!options.tokenId) { 70 | const id = uuidv4() 71 | this.tokenId = crypto 72 | .createHash('sha256') 73 | .update(Buffer.from(id)) 74 | .digest() 75 | } else { 76 | this.tokenId = options.tokenId 77 | } 78 | assert(this.tokenId.length === TOKEN_ID_SIZE, 'Token Id of unexpected size') 79 | 80 | return this 81 | } 82 | 83 | /** 84 | * Convert identifier to string 85 | * @returns {string} 86 | */ 87 | toString(): string { 88 | return this.toHex() 89 | } 90 | 91 | static fromString(str: string): Identifier { 92 | try { 93 | return new this().fromHex(str) 94 | } catch (e) { 95 | return new this().fromBase64(str) 96 | } 97 | } 98 | 99 | /** 100 | * Utility for encoding the Identifier into a buffer based on version 101 | * @param {bufio.BufferWriter} bw - Buffer writer for creating an Identifier Buffer 102 | * @returns {Identifier} 103 | */ 104 | write(bw: any): this { 105 | bw.writeU16BE(this.version) 106 | 107 | switch (this.version) { 108 | case 0: 109 | // write payment hash 110 | bw.writeHash(this.paymentHash) 111 | 112 | // check format of tokenId 113 | assert( 114 | Buffer.isBuffer(this.tokenId) && 115 | this.tokenId.length === TOKEN_ID_SIZE, 116 | `Token ID must be ${TOKEN_ID_SIZE}-byte hash` 117 | ) 118 | 119 | // write tokenId 120 | bw.writeBytes(this.tokenId) 121 | return this 122 | default: 123 | throw new ErrUnknownVersion(this.version) 124 | } 125 | } 126 | 127 | /** 128 | * Utility for reading raw Identifier bytes and converting to a new Identifier 129 | * @param {bufio.BufferReader} br - Buffer Reader to read bytes 130 | * @returns {Identifier} 131 | */ 132 | read(br: any): this { 133 | this.version = br.readU16BE() 134 | 135 | switch (this.version) { 136 | case 0: 137 | this.paymentHash = br.readHash() 138 | this.tokenId = br.readBytes(TOKEN_ID_SIZE) 139 | return this 140 | default: 141 | throw new ErrUnknownVersion(this.version) 142 | } 143 | } 144 | } 145 | 146 | export const decodeIdentifierFromMacaroon = (raw: string): string => { 147 | const macaroon = Macaroon.importMacaroon(raw) 148 | let identifier = macaroon._exportAsJSONObjectV2().i 149 | if (identifier == undefined) { 150 | identifier = macaroon._exportAsJSONObjectV2().i64 151 | if (identifier == undefined) { 152 | throw new Error(`Problem parsing macaroon identifier`) 153 | } 154 | } 155 | return identifier 156 | } 157 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './identifier' 2 | export * from './caveat' 3 | export * from './lsat' 4 | export * from './types' 5 | export * from './satisfiers' 6 | export * from './macaroon' 7 | export * from './service' 8 | -------------------------------------------------------------------------------- /src/lsat.ts: -------------------------------------------------------------------------------- 1 | const assert = require('bsert') 2 | const bufio = require('bufio') 3 | 4 | import crypto from 'crypto' 5 | import * as Macaroon from 'macaroon' 6 | 7 | import { Caveat, decodeIdentifierFromMacaroon, Identifier, getRawMacaroon } from '.' 8 | import { LsatOptions } from './types' 9 | import { isHex, getIdFromRequest, decode } from './helpers' 10 | 11 | type LsatJson = { 12 | validUntil: number, 13 | isPending: boolean, 14 | isSatisfied: boolean, 15 | invoiceAmount: number 16 | } & LsatOptions 17 | 18 | /** Helpers */ 19 | 20 | export function parseChallengePart(challenge:string): string { 21 | let macaroon 22 | const separatorIndex = challenge.indexOf('=') 23 | assert(separatorIndex > -1, 'Incorrectly encoded challenge. Missing "=" separator.') 24 | 25 | // slice off `[challengeType]=` 26 | const splitIndex = challenge.length - 1 - separatorIndex; 27 | macaroon = challenge.slice(-splitIndex) 28 | assert(macaroon.length, 'Incorrectly encoded macaroon challenge') 29 | 30 | assert( 31 | macaroon[0] === '"' && macaroon[macaroon.length -1] === '"', 32 | 'Incorectly encoded challenge, challenges must be enclosed in double quotes.' 33 | ) 34 | macaroon = macaroon.slice(1, macaroon.length - 1) 35 | return macaroon 36 | } 37 | 38 | /** 39 | * @description A a class for creating and converting LSATs 40 | */ 41 | export class Lsat extends bufio.Struct { 42 | id: string 43 | baseMacaroon: string 44 | paymentHash: string 45 | paymentPreimage: string | null 46 | validUntil: number 47 | timeCreated: number 48 | invoice: string 49 | amountPaid: number | null 50 | routingFeePaid: number | null 51 | invoiceAmount: number 52 | 53 | static type = 'LSAT' 54 | 55 | constructor(options: LsatOptions) { 56 | super(options) 57 | this.id = '' 58 | this.validUntil = 0 59 | this.invoice = '' 60 | this.baseMacaroon = '' 61 | this.paymentHash = Buffer.alloc(32).toString('hex') 62 | this.timeCreated = Date.now() 63 | this.paymentPreimage = null 64 | this.amountPaid = 0 65 | this.routingFeePaid = 0 66 | this.invoiceAmount = 0 67 | 68 | if (options) this.fromOptions(options) 69 | } 70 | 71 | fromOptions(options: LsatOptions): this { 72 | assert( 73 | typeof options.baseMacaroon === 'string', 74 | 'Require serialized macaroon' 75 | ) 76 | this.baseMacaroon = options.baseMacaroon 77 | 78 | assert(typeof options.id === 'string', 'Require string id') 79 | this.id = options.id 80 | 81 | assert(typeof options.paymentHash === 'string', 'Require paymentHash') 82 | this.paymentHash = options.paymentHash 83 | 84 | const expiration = this.getExpirationFromMacaroon(options.baseMacaroon) 85 | if (expiration) this.validUntil = expiration 86 | 87 | if (options.invoice) { 88 | this.addInvoice(options.invoice) 89 | } 90 | 91 | if (options.timeCreated) this.timeCreated = options.timeCreated 92 | 93 | if (options.paymentPreimage) this.paymentPreimage = options.paymentPreimage 94 | 95 | if (options.amountPaid) this.amountPaid = options.amountPaid 96 | 97 | if (options.routingFeePaid) this.routingFeePaid = options.routingFeePaid 98 | 99 | return this 100 | } 101 | 102 | /** 103 | * @description Determine if the LSAT is expired or not. This is based on the 104 | * `validUntil` property of the lsat which is evaluated at creation time 105 | * based on the macaroon and any existing expiration caveats 106 | * @returns {boolean} 107 | */ 108 | isExpired(): boolean { 109 | if (this.validUntil === 0) return false 110 | return this.validUntil < Date.now() 111 | } 112 | 113 | /** 114 | * @description Determines if the lsat is pending based on if it has a preimage 115 | * @returns {boolean} 116 | */ 117 | isPending(): boolean { 118 | return this.paymentPreimage ? false : true 119 | } 120 | 121 | /** 122 | * @description Determines if the lsat is valid based on a valid preimage or not 123 | * @returns {boolean} 124 | */ 125 | isSatisfied(): boolean { 126 | if (!this.paymentHash) return false 127 | if (!this.paymentPreimage) return false 128 | const hash = crypto 129 | .createHash('sha256') 130 | .update(Buffer.from(this.paymentPreimage, 'hex')) 131 | .digest('hex') 132 | if (hash !== this.paymentHash) return false 133 | return true 134 | } 135 | 136 | /** 137 | * @description Gets the base macaroon from the lsat 138 | * @returns {MacaroonInterface} 139 | */ 140 | getMacaroon(): Macaroon.MacaroonJSONV2 { 141 | return Macaroon.importMacaroon(this.baseMacaroon)._exportAsJSONObjectV2() 142 | } 143 | 144 | /** 145 | * @description A utility for returning the expiration date of the LSAT's macaroon based on 146 | * an optional caveat 147 | * @param {string} [macaroon] - raw macaroon to get expiration date from if exists as a caveat. If 148 | * none is provided then it will use LSAT's base macaroon. Will throw if neither exists 149 | * @returns {number} expiration date 150 | */ 151 | getExpirationFromMacaroon(macaroon?: string): number { 152 | if (!macaroon) macaroon = this.baseMacaroon 153 | assert(macaroon, 'Missing macaroon') 154 | 155 | const caveatPackets = Macaroon.importMacaroon(macaroon)._exportAsJSONObjectV2().c 156 | const expirationCaveats = [] 157 | if (caveatPackets == undefined) { 158 | return 0 159 | } 160 | for (const cav of caveatPackets) { 161 | if (cav.i == undefined) { 162 | continue 163 | } 164 | const caveat = Caveat.decode(cav.i) 165 | if (caveat.condition === 'expiration') expirationCaveats.push(caveat) 166 | } 167 | 168 | // return zero if no expiration caveat 169 | if (!expirationCaveats.length) return 0 170 | 171 | // want to return the last expiration caveat 172 | return Number(expirationCaveats[expirationCaveats.length - 1].value) 173 | } 174 | 175 | /** 176 | * @description A utility for setting the preimage for an LSAT. This method will validate the preimage and throw 177 | * if it is either of the incorrect length or does not match the paymentHash 178 | * @param {string} preimage - 32-byte hex string of the preimage that is used as proof of payment of a lightning invoice 179 | */ 180 | setPreimage(preimage: string): void { 181 | assert( 182 | isHex(preimage) && preimage.length === 64, 183 | 'Must pass valid 32-byte hash for lsat secret' 184 | ) 185 | 186 | const hash = crypto 187 | .createHash('sha256') 188 | .update(Buffer.from(preimage, 'hex')) 189 | .digest('hex') 190 | 191 | assert( 192 | hash === this.paymentHash, 193 | "Hash of preimage did not match LSAT's paymentHash" 194 | ) 195 | this.paymentPreimage = preimage 196 | } 197 | 198 | /** 199 | * @description Add a first party caveat onto the lsat's base macaroon. 200 | * This method does not validate the caveat being added. So, for example, a 201 | * caveat that would fail validation on submission could still be added (e.g. an 202 | * expiration that is less restrictive then a previous one). This should be done by 203 | * the implementer 204 | * @param {Caveat} caveat - caveat to add to the macaroon 205 | * @returns {void} 206 | */ 207 | addFirstPartyCaveat(caveat: Caveat): void { 208 | assert( 209 | caveat instanceof Caveat, 210 | 'Require a Caveat object to add to macaroon' 211 | ) 212 | 213 | const mac = Macaroon.importMacaroon(this.baseMacaroon) 214 | mac.addFirstPartyCaveat(caveat.encode()) 215 | this.baseMacaroon = getRawMacaroon(mac) 216 | } 217 | 218 | /** 219 | * @description Get a list of caveats from the base macaroon 220 | * @returns {Caveat[]} caveats - list of caveats 221 | */ 222 | 223 | getCaveats(): Caveat[] { 224 | const caveats: Caveat[] = [] 225 | const caveatPackets = this.getMacaroon().c 226 | if (caveatPackets == undefined){ 227 | return caveats 228 | } 229 | for (const cav of caveatPackets) { 230 | if (cav.i == undefined) { 231 | continue 232 | } 233 | caveats.push(Caveat.decode(cav.i)) 234 | } 235 | return caveats 236 | } 237 | /** 238 | * @description Converts the lsat into a valid LSAT token for use in an http 239 | * Authorization header. This will return a string in the form: "LSAT [macaroon]:[preimage?]". 240 | * If no preimage is available the last character should be a colon, which would be 241 | * an "incomplete" LSAT 242 | * @returns {string} 243 | */ 244 | toToken(): string { 245 | return `LSAT ${this.baseMacaroon}:${this.paymentPreimage || ''}` 246 | } 247 | 248 | /** 249 | * @description Converts LSAT into a challenge header to return in the WWW-Authenticate response 250 | * header. Returns base64 encoded string with macaroon and invoice information prefixed with 251 | * authentication type ("LSAT") 252 | * @returns {string} 253 | */ 254 | toChallenge(): string { 255 | assert( 256 | this.invoice, 257 | `Can't create a challenge without a payment request/invoice` 258 | ) 259 | const challenge = `macaroon="${this.baseMacaroon}", invoice="${this.invoice}"` 260 | return `LSAT ${challenge}` 261 | } 262 | 263 | toJSON(): LsatJson { 264 | return { 265 | id: this.id, 266 | validUntil: this.validUntil, 267 | invoice: this.invoice, 268 | baseMacaroon: this.baseMacaroon, 269 | paymentHash: this.paymentHash, 270 | timeCreated: this.timeCreated, 271 | paymentPreimage: this.paymentPreimage || undefined, 272 | amountPaid: this.amountPaid || undefined, 273 | invoiceAmount: this.invoiceAmount, 274 | routingFeePaid: this.routingFeePaid || undefined, 275 | isPending: this.isPending(), 276 | isSatisfied: this.isSatisfied() 277 | } 278 | } 279 | 280 | addInvoice(invoice: string): void { 281 | assert(this.paymentHash, 'Cannot add invoice data to an LSAT without paymentHash') 282 | try { 283 | type Tag = {tagName: string, data?: string} 284 | const data = decode(invoice) 285 | const { satoshis: tokens } = data 286 | const hashTag = data.tags.find((tag: Tag) => tag.tagName === 'payment_hash') 287 | assert(hashTag, 'Could not find payment hash on invoice request') 288 | const paymentHash = hashTag?.data 289 | 290 | assert( 291 | paymentHash === this.paymentHash, 292 | 'paymentHash from invoice did not match LSAT' 293 | ) 294 | this.invoiceAmount = tokens || 0 295 | this.invoice = invoice 296 | } catch (e:any) { 297 | throw new Error(`Problem adding invoice data to LSAT: ${e.message}`) 298 | } 299 | } 300 | // Static API 301 | 302 | /** 303 | * @description generates a new LSAT from an invoice and an optional invoice 304 | * @param {string} macaroon - macaroon to parse and generate relevant lsat properties from 305 | * @param {string} [invoice] - optional invoice which can provide other relevant information for the lsat 306 | */ 307 | static fromMacaroon(macaroon: string, invoice?: string): Lsat { 308 | assert(typeof macaroon === 'string', 'Requires a raw macaroon string for macaroon to generate LSAT') 309 | let id: Identifier, identifier: string 310 | try { 311 | identifier = decodeIdentifierFromMacaroon(macaroon) 312 | id = Identifier.fromString(identifier) 313 | } catch (e:any) { 314 | throw new Error( 315 | `Unexpected encoding for macaroon identifier: ${e.message}` 316 | ) 317 | } 318 | 319 | const options: LsatOptions = { 320 | id: identifier, 321 | baseMacaroon: macaroon, 322 | paymentHash: id.paymentHash.toString('hex'), 323 | } 324 | const lsat = new this(options) 325 | 326 | if (invoice) { 327 | lsat.addInvoice(invoice) 328 | } 329 | 330 | return lsat 331 | } 332 | 333 | /** 334 | * @description Create an LSAT from an http Authorization header. A useful utility 335 | * when trying to parse an LSAT sent in a request and determining its validity 336 | * @param {string} token - LSAT token sent in request 337 | * @param {string} invoice - optional payment request information to intialize lsat with 338 | * @returns {Lsat} 339 | */ 340 | static fromToken(token: string, invoice?: string): Lsat { 341 | assert(token.includes(this.type), 'Token must include LSAT prefix') 342 | token = token.slice(this.type.length).trim() 343 | const [macaroon, preimage] = token.split(':') 344 | const lsat = Lsat.fromMacaroon(macaroon, invoice) 345 | 346 | if (preimage) lsat.setPreimage(preimage) 347 | return lsat 348 | } 349 | 350 | /** 351 | * @description Validates and converts an LSAT challenge from a WWW-Authenticate header 352 | * response into an LSAT object. This method expects an invoice and a macaroon in the challenge 353 | * @param {string} challenge 354 | * @returns {Lsat} 355 | */ 356 | static fromChallenge(challenge: string): Lsat { 357 | const macChallenge = 'macaroon=' 358 | const invoiceChallenge = 'invoice=' 359 | 360 | let challenges: string[] 361 | 362 | challenges = challenge.split(',') 363 | 364 | // add support for challenges that are separated with just a space 365 | if (challenges.length < 2) challenges = challenge.split(' ') 366 | 367 | // if we still don't have at least two, then there was a malformed header/challenge 368 | assert( 369 | challenges.length >= 2, 370 | 'Expected at least two challenges in the LSAT: invoice and macaroon' 371 | ) 372 | 373 | let macaroon = '', 374 | invoice = '' 375 | 376 | // get the indexes of the challenge strings so that we can split them 377 | // kind of convoluted but it takes into account challenges being in the wrong order 378 | // and for excess challenges that we can ignore 379 | for (const c of challenges) { 380 | // check if we're looking at the macaroon challenge 381 | if (!macaroon.length && c.indexOf(macChallenge) > -1) { 382 | try { 383 | macaroon = parseChallengePart(c) 384 | } catch (e:any) { 385 | throw new Error(`Problem parsing macaroon challenge: ${e.message}`) 386 | } 387 | } 388 | 389 | // check if we're looking at the invoice challenge 390 | if (!invoice.length && c.indexOf(invoiceChallenge) > -1) { 391 | try { 392 | invoice = parseChallengePart(c) 393 | } catch (e:any) { 394 | throw new Error(`Problem parsing macaroon challenge: ${e.message}`) 395 | } 396 | } 397 | // if there are other challenges but we have mac and invoice then we can break 398 | // as they are not LSAT relevant anyway 399 | if (invoice.length && macaroon.length) break 400 | } 401 | 402 | assert( 403 | invoice.length && macaroon.length, 404 | 'Expected WWW-Authenticate challenge with macaroon and invoice data' 405 | ) 406 | 407 | const paymentHash = getIdFromRequest(invoice) 408 | const identifier = decodeIdentifierFromMacaroon(macaroon) 409 | 410 | return new this({ 411 | id: identifier, 412 | baseMacaroon: macaroon, 413 | paymentHash, 414 | invoice: invoice, 415 | }) 416 | } 417 | 418 | /** 419 | * @description Given an LSAT WWW-Authenticate challenge header (with token type, "LSAT", prefix) 420 | * will return an Lsat. 421 | * @param header 422 | */ 423 | static fromHeader(header: string): Lsat { 424 | // remove the token type prefix to get the challenge 425 | const challenge = header.slice(this.type.length).trim() 426 | 427 | assert( 428 | header.length !== challenge.length, 429 | 'header missing token type prefix "LSAT"' 430 | ) 431 | 432 | return Lsat.fromChallenge(challenge) 433 | } 434 | } -------------------------------------------------------------------------------- /src/macaroon.ts: -------------------------------------------------------------------------------- 1 | import { Caveat, verifyCaveats } from "./caveat"; 2 | import { stringToBytes } from './helpers' 3 | import * as Macaroon from 'macaroon' 4 | import { MacaroonClass, Satisfier } from "./types"; 5 | import { bytesToBase64 } from "macaroon/src/macaroon"; 6 | import { encode, encodeURLSafe } from "@stablelib/base64"; 7 | 8 | /** 9 | * @description utility function to get an array of caveat instances from 10 | * a raw macaroon. 11 | * @param {string} macaroon - raw macaroon to retrieve caveats from 12 | * @returns {Caveat[]} array of caveats on the macaroon 13 | */ 14 | export function getCaveatsFromMacaroon(rawMac: string): Caveat[] { 15 | const macaroon = Macaroon.importMacaroon(rawMac) 16 | const caveats = [] 17 | const rawCaveats = macaroon._exportAsJSONObjectV2()?.c 18 | 19 | if (rawCaveats) { 20 | for (const c of rawCaveats) { 21 | if (!c.i) continue; 22 | const caveat = Caveat.decode(c.i) 23 | caveats.push(caveat) 24 | } 25 | } 26 | return caveats 27 | } 28 | 29 | /** 30 | * @description verifyMacaroonCaveats will check if a macaroon is valid or 31 | * not based on a set of satisfiers to pass as general caveat verifiers. This will also run 32 | * against caveat.verifyCaveats to ensure that satisfyPrevious will validate 33 | * @param {string} macaroon A raw macaroon to run a verifier against 34 | * @param {String} secret The secret key used to sign the macaroon 35 | * @param {(Satisfier | Satisfier[])} satisfiers a single satisfier or list of satisfiers used to verify caveats 36 | * @param {Object} [options] An optional options object that will be passed to the satisfiers. 37 | * In many circumstances this will be a request object, for example when this is used in a server 38 | * @returns {boolean} 39 | */ 40 | export function verifyMacaroonCaveats( 41 | rawMac: string, 42 | secret: string, 43 | satisfiers?: Satisfier | Satisfier[], 44 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 45 | options: any = {} 46 | ): boolean { 47 | try { 48 | const macaroon = Macaroon.importMacaroon(rawMac) 49 | const secretBytesArray = stringToBytes(secret) 50 | 51 | // js-macaroon's verification takes a function as its second 52 | // arg that runs a check against each caveat which is a less full-featured 53 | // version of `verifyCaveats` used below since it doesn't support contextual 54 | // checks like comparing w/ previous caveats for the same condition. 55 | // we pass this stubbed function so signature checks can be done 56 | // and satisfier checks will be done next if this passes. 57 | macaroon.verify(secretBytesArray, () => null) 58 | 59 | const caveats = getCaveatsFromMacaroon(rawMac) 60 | 61 | if (satisfiers && !Array.isArray(satisfiers)) satisfiers = [satisfiers] 62 | if (!caveats.length && (!satisfiers || !satisfiers.length)) return true; 63 | // check caveats against satisfiers, including previous caveat checks 64 | return verifyCaveats(caveats, satisfiers, options) 65 | } catch (e) { 66 | return false 67 | } 68 | } 69 | 70 | /** 71 | * A convenience wrapper for getting a base64 encoded string. 72 | * We unfortunately can't use the built in tool `Macaroon#bytesToBase64` 73 | * because it only supports url safe base64 encoding which isn't compatible with 74 | * aperture 75 | * @param mac MacaroonClass - a macaroon to convert to raw base64 76 | * @returns base64 string 77 | */ 78 | export function getRawMacaroon(mac: MacaroonClass, urlSafe=false): string { 79 | const bytes = mac._exportBinaryV2() 80 | if (urlSafe) return encodeURLSafe(bytes) 81 | return encode(bytes) 82 | } 83 | -------------------------------------------------------------------------------- /src/satisfiers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Useful satisfiers that are independent of environment, for example, 3 | * ones that don't require the request object in a server as these can be used anywhere. 4 | */ 5 | 6 | import { 7 | Satisfier, 8 | Caveat, 9 | InvalidServicesError, 10 | SERVICES_CAVEAT_CONDITION, 11 | decodeServicesCaveat, 12 | InvalidCapabilitiesError, 13 | SERVICE_CAPABILITIES_SUFFIX, 14 | decodeCapabilitiesValue, 15 | } from '.' 16 | 17 | /** 18 | * @description A satisfier for validating expiration caveats on macaroon. Used in the exported 19 | * boltwallConfig TIME_CAVEAT_CONFIGS 20 | * @type Satisfier 21 | */ 22 | 23 | export const expirationSatisfier: Satisfier = { 24 | condition: 'expiration', 25 | satisfyPrevious: (prev: Caveat, curr: Caveat) => { 26 | if (prev.condition !== 'expiration' || curr.condition !== 'expiration') 27 | return false 28 | // fails if current expiration is later than previous 29 | // (i.e. newer caveats should be more restrictive) 30 | else if (prev.value < curr.value) return false 31 | else return true 32 | }, 33 | satisfyFinal: (caveat: Caveat) => { 34 | if (caveat.condition !== 'expiration') return false 35 | // if the expiration value is less than current time than satisfier is failed 36 | if (caveat.value < Date.now()) return false 37 | return true 38 | }, 39 | } 40 | 41 | export const createServicesSatisfier = (targetService: string): Satisfier => { 42 | // validate targetService 43 | if (typeof targetService !== 'string') throw new InvalidServicesError() 44 | 45 | return { 46 | condition: SERVICES_CAVEAT_CONDITION, 47 | satisfyPrevious: (prev: Caveat, curr: Caveat): boolean => { 48 | const prevServices = decodeServicesCaveat(prev.value.toString()) 49 | const currentServices = decodeServicesCaveat(curr.value.toString()) 50 | 51 | // making typescript happy 52 | if (!Array.isArray(prevServices) || !Array.isArray(currentServices)) 53 | throw new InvalidServicesError() 54 | 55 | // Construct a set of the services we were previously 56 | // allowed to access. 57 | let previouslyAllowed = new Map() 58 | previouslyAllowed = prevServices.reduce( 59 | (prev, current) => prev.set(current.name, current.tier), 60 | previouslyAllowed 61 | ) 62 | 63 | // The caveat should not include any new services that 64 | // weren't previously allowed. 65 | for (const service of currentServices) { 66 | if (!previouslyAllowed.has(service.name)) return false 67 | // confirm that previous service tier cannot be higher than current 68 | const prevTier: number = previouslyAllowed.get(service.name) 69 | if (prevTier > service.tier) return false 70 | } 71 | 72 | return true 73 | }, 74 | 75 | satisfyFinal: (caveat: Caveat): boolean => { 76 | const services = decodeServicesCaveat(caveat.value.toString()) 77 | // making typescript happy 78 | if (!Array.isArray(services)) throw new InvalidServicesError() 79 | 80 | for (const service of services) { 81 | if (service.name === targetService) return true 82 | } 83 | return false 84 | }, 85 | } 86 | } 87 | 88 | export const createCapabilitiesSatisfier = ( 89 | service: string, 90 | targetCapability: string 91 | ): Satisfier => { 92 | // validate targetService 93 | if (typeof targetCapability !== 'string') throw new InvalidCapabilitiesError() 94 | if (typeof service !== 'string') throw new InvalidCapabilitiesError() 95 | 96 | return { 97 | condition: service + SERVICE_CAPABILITIES_SUFFIX, 98 | satisfyPrevious: (prev: Caveat, curr: Caveat): boolean => { 99 | const prevCapabilities = decodeCapabilitiesValue(prev.value.toString()) 100 | const currentCapabilities = decodeCapabilitiesValue(curr.value.toString()) 101 | 102 | // making typescript happy 103 | if ( 104 | !Array.isArray(prevCapabilities) || 105 | !Array.isArray(currentCapabilities) 106 | ) 107 | throw new InvalidServicesError() 108 | 109 | // Construct a set of the service's capabilities we were 110 | // previously allowed to access. 111 | let previouslyAllowed = new Set() 112 | previouslyAllowed = prevCapabilities.reduce( 113 | (prev, current) => prev.add(current), 114 | previouslyAllowed 115 | ) 116 | 117 | // The caveat should not include any new service 118 | // capabilities that weren't previously allowed. 119 | for (const capability of currentCapabilities) { 120 | if (!previouslyAllowed.has(capability)) return false 121 | } 122 | 123 | return true 124 | }, 125 | satisfyFinal: (caveat: Caveat): boolean => { 126 | const capabilities = decodeCapabilitiesValue(caveat.value.toString()) 127 | // making typescript happy 128 | if (!Array.isArray(capabilities)) throw new InvalidServicesError() 129 | 130 | for (const capability of capabilities) { 131 | if (capability === targetCapability) return true 132 | } 133 | return false 134 | }, 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/service.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Services are a special kind of caveat based off of 3 | * the official lsat spec by 4 | * [Lightning Labs](https://lsat.tech/macaroons#target-services). 5 | * These have certain expectations around value encoding and also support 6 | * tiers of capabilities where services have service level capabilities and 7 | * capabilities in turn can have constraints. 8 | * See below for an example from lightning loop. 9 | * 10 | * @example 11 | * services = lightning_loop:0 12 | * lightning_loop_capabilities = loop_out,loop_in 13 | * loop_out_monthly_volume_sats = 200000000 14 | * 15 | */ 16 | 17 | import bufio from 'bufio' 18 | import { Caveat } from './caveat' 19 | 20 | export class NoServicesError extends Error { 21 | constructor(...params: string[]) { 22 | super(...params) 23 | this.name = 'NoServicesError' 24 | this.message = 'no services found' 25 | // Maintains proper stack trace for where our error was thrown (only available on V8) 26 | if (Error.captureStackTrace) { 27 | Error.captureStackTrace(this, NoServicesError) 28 | } 29 | } 30 | } 31 | 32 | export class InvalidServicesError extends Error { 33 | constructor(message?: string) { 34 | super(message) 35 | this.name = 'InvalidServicesError' 36 | if (!message) this.message = 'service must be of the form "name:tier"' 37 | // Maintains proper stack trace for where our error was thrown (only available on V8) 38 | if (Error.captureStackTrace) { 39 | Error.captureStackTrace(this, InvalidServicesError) 40 | } 41 | } 42 | } 43 | 44 | export class InvalidCapabilitiesError extends Error { 45 | constructor(message?: string) { 46 | super(message) 47 | this.name = 'InvalidCapabilitiesError' 48 | if (!message) 49 | this.message = 'capabilities must be a string or array of strings' 50 | // Maintains proper stack trace for where our error was thrown (only available on V8) 51 | if (Error.captureStackTrace) { 52 | Error.captureStackTrace(this, InvalidServicesError) 53 | } 54 | } 55 | } 56 | 57 | export interface ServiceClassOptions { 58 | name: string 59 | tier: number 60 | } 61 | 62 | export class Service extends bufio.Struct { 63 | name: string 64 | tier: number 65 | 66 | constructor(options: ServiceClassOptions) { 67 | super(options) 68 | this.name = options.name 69 | this.tier = options.tier 70 | } 71 | } 72 | 73 | // the condition value in a caveat for services 74 | export const SERVICES_CAVEAT_CONDITION = 'services' 75 | 76 | /** 77 | * 78 | * @param {string} s - raw services string of format `name:tier,name:tier` 79 | * @returns array of Service objects or throws an error 80 | */ 81 | export const decodeServicesCaveat = (s: string): Service[] | Error => { 82 | if (!s.length) throw new NoServicesError() 83 | 84 | const services: Service[] = [] 85 | const rawServices = s.split(',') 86 | 87 | for (const serviceString of rawServices) { 88 | const [service, tier] = serviceString.split(':') 89 | // validation 90 | if (!service || !tier) throw new InvalidServicesError() 91 | if (isNaN(+tier)) throw new InvalidServicesError('tier must be a number') 92 | if (!isNaN(+service)) 93 | throw new InvalidServicesError('service name must be a string') 94 | services.push(new Service({ name: service, tier: +tier })) 95 | } 96 | 97 | return services 98 | } 99 | 100 | export const encodeServicesCaveatValue = ( 101 | services: Service[] 102 | ): string | Error => { 103 | if (!services.length) throw new NoServicesError() 104 | 105 | let rawServices = '' 106 | 107 | for (let i = 0; i < services.length; i++) { 108 | const service = services[i] 109 | if (!(service instanceof Service)) 110 | throw new InvalidServicesError('not a valid service') 111 | if (!service.name) 112 | throw new InvalidServicesError('service must nave a name') 113 | if (service.tier !== 0 && !service.tier) 114 | throw new InvalidServicesError('service must have a tier') 115 | 116 | rawServices = rawServices.concat(`${service.name}:${service.tier}`) 117 | // add a comma at the end if it's not the same 118 | if (i !== services.length - 1) rawServices = `${rawServices},` 119 | } 120 | return rawServices 121 | } 122 | 123 | export const SERVICE_CAPABILITIES_SUFFIX = '_capabilities' 124 | 125 | export const createNewCapabilitiesCaveat = ( 126 | serviceName: string, 127 | _capabilities?: string | string[] 128 | ): Caveat => { 129 | let capabilities: string 130 | if (!_capabilities) { 131 | capabilities = '' 132 | } else if (Array.isArray(_capabilities)) { 133 | capabilities = _capabilities.join(',') 134 | } else if (typeof _capabilities !== 'string') { 135 | throw new InvalidCapabilitiesError() 136 | } else { 137 | capabilities = _capabilities 138 | } 139 | 140 | return new Caveat({ 141 | condition: serviceName + SERVICE_CAPABILITIES_SUFFIX, 142 | value: capabilities, 143 | comp: '=', 144 | }) 145 | } 146 | 147 | export const decodeCapabilitiesValue = (value: string): string[] => { 148 | if (typeof value !== 'string') throw new InvalidCapabilitiesError() 149 | return value 150 | .toString() 151 | .split(',') 152 | .map((s: string) => s.trim()) 153 | } 154 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { MacaroonJSONV2 } from 'macaroon' 2 | 3 | export * from './lsat' 4 | export * from './satisfier' 5 | 6 | // js-macaroon doesn't export a type for its base class 7 | // this throws off some of the ts linting when it wants a return type 8 | /** 9 | * @typedef {Object} MacaroonClass 10 | */ 11 | export interface MacaroonClass { 12 | _exportAsJSONObjectV2(): MacaroonJSONV2 13 | addFirstPartyCaveat(caveatIdBytes: Uint8Array | string): void 14 | _exportBinaryV2(): Uint8Array 15 | } 16 | 17 | // could maybe do the above as -> typeof Macaroon.newMacaroon({...}) 18 | -------------------------------------------------------------------------------- /src/types/lsat.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} IdentifierOptions 3 | * @property {number} version - version of the Identifier. Used for serialization 4 | * @property {Buffer} paymentHash - payment hash of invoice associated with LSAT 5 | * @property {Buffer} tokenId - unique identifier for the LSAT 6 | * Describes the shape of the options for creating a new identifier struct 7 | * which represents the constant, unique identifiers associated with a macaroon 8 | */ 9 | export interface IdentifierOptions { 10 | version?: number 11 | paymentHash: Buffer 12 | tokenId?: Buffer 13 | } 14 | 15 | /** 16 | * @typedef {Object} CaveatOptions 17 | * @property {string} condition - the key used to identify the caveat 18 | * @property {string|number} value - value for the caveat to be compared against 19 | * @property {string} comp - a comparator string for how the value should be evaluated 20 | * Describes options to create a caveat. The condition is like the variable 21 | * and the value is what it is expected to be. Encoded format would be "condition=value" 22 | */ 23 | export interface CaveatOptions { 24 | condition: string 25 | value: string | number 26 | comp?: string 27 | } 28 | 29 | /** 30 | * @typedef LsatOptions 31 | * Describes options to create an LSAT token. 32 | */ 33 | export interface LsatOptions { 34 | id: string 35 | baseMacaroon: string 36 | paymentHash: string 37 | invoice?: string 38 | timeCreated?: number 39 | paymentPreimage?: string 40 | amountPaid?: number 41 | routingFeePaid?: number 42 | } 43 | -------------------------------------------------------------------------------- /src/types/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'bsert' 2 | declare module 'bufio' 3 | declare module 'bsert' 4 | declare module 'bolt11' 5 | -------------------------------------------------------------------------------- /src/types/satisfier.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { Caveat } from '../caveat' 3 | 4 | /** 5 | * @typedef {function (prev: Caveat, curr: Caveat, options: object): boolean} SatisfyPrevious 6 | * @description A satisfier function for comparing two caveats to ensure increasing restrictiveness. 7 | * @param {Caveat} prev - preceding caveat 8 | * @param {Caveat} curr - current caveat 9 | * @param {Object} options - optional object to be used to make evaluation, e.g. a request object in a server 10 | * @returns {boolean} 11 | */ 12 | export type SatisfyPrevious = ( 13 | prev: Caveat, 14 | curr: Caveat, 15 | options?: any 16 | ) => boolean 17 | 18 | /** 19 | * @typedef {function (caveat: Caveat, options: object): boolean} SatisfyFinal 20 | * @description A satisfier function to evaluate if a caveat has been satisfied 21 | * @param {Caveat} caveat - caveat to evaluate 22 | * @param {Object} options - optional object to be used to make evaluation, e.g. a request object in a server 23 | * @returns boolean 24 | */ 25 | export type SatisfyFinal = (caveat: Caveat, options?: any) => boolean 26 | 27 | /** 28 | * @typedef {Object} Satisfier 29 | * @description Satisfier provides a generic interface to satisfy a caveat based on its 30 | * condition. 31 | * @property {string} condition - used to identify the caveat to check against 32 | * @property {SatisfyPrevious} satisfyPrevious 33 | * @property {SatisfyFinal} satisfyFinal 34 | */ 35 | export interface Satisfier { 36 | condition: string 37 | satisfyPrevious?: SatisfyPrevious 38 | satisfyFinal: SatisfyFinal 39 | } 40 | -------------------------------------------------------------------------------- /tests/.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-pro 2 | -------------------------------------------------------------------------------- /tests/.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" 4 | cache: yarn 5 | script: 6 | - yarn lint 7 | - yarn build 8 | - yarn test 9 | after_success: yarn run coverage 10 | -------------------------------------------------------------------------------- /tests/caveat.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Macaroon from 'macaroon' 3 | import { Caveat, ErrInvalidCaveat, getRawMacaroon, hasCaveat, verifyCaveats } from '../src' 4 | 5 | import { Satisfier } from '../src/types' 6 | 7 | describe('Caveats', () => { 8 | describe('Caveat', () => { 9 | it('should be able to encode a caveat for: =, <, >', () => { 10 | const caveats = [ 11 | 'expiration=1337', 12 | 'time<1337', 13 | 'time>1337', 14 | 'expiration=1338=', 15 | ] 16 | 17 | caveats.forEach((c: string) => { 18 | const testCaveat = (): Caveat => Caveat.decode(c) 19 | expect(testCaveat).not.to.throw() 20 | const caveat = Caveat.decode(c) 21 | expect(caveat.encode()).to.equal(c) 22 | }) 23 | }) 24 | 25 | it('should trim whitespace from caveats', () => { 26 | const caveats = [ 27 | { caveat: ' expiration = 1338', expected: 'expiration=1338' }, 28 | ] 29 | 30 | caveats.forEach(c => { 31 | const testCaveat = (): Caveat => Caveat.decode(c.caveat) 32 | expect(testCaveat).not.to.throw() 33 | const caveat = Caveat.decode(c.caveat) 34 | expect(caveat.encode()).to.equal(c.expected) 35 | }) 36 | }) 37 | 38 | it('should throw if given an incorrectly encoded caveat', () => { 39 | const caveats = ['expiration:1337'] 40 | 41 | caveats.forEach((c: string) => { 42 | const testCaveat = (): Caveat => Caveat.decode(c) 43 | expect(testCaveat).to.throw(ErrInvalidCaveat) 44 | }) 45 | }) 46 | }) 47 | 48 | describe('hasCaveats', () => { 49 | it('should return the value for the last instance of a caveat with given condition on a macaroon', () => { 50 | const condition = 'expiration' 51 | const value = 100 52 | 53 | const caveat = new Caveat({ condition, value }) 54 | 55 | const macaroon = Macaroon.newMacaroon({ 56 | version: 1, 57 | rootKey: 'secret', 58 | identifier: 'pubId', 59 | location: 'location', 60 | }) 61 | macaroon.addFirstPartyCaveat(caveat.encode()) 62 | 63 | const macb64 = getRawMacaroon(macaroon) 64 | // check that it returns the value for the caveat we're checking for 65 | expect(hasCaveat(macb64, caveat)).to.equal( 66 | caveat.value && caveat.value.toString() 67 | ) 68 | 69 | // check that it will return false for a caveat that it doesn't have 70 | const fakeCaveat = new Caveat({ condition: 'foo', value: 'bar' }) 71 | expect(hasCaveat(macb64, fakeCaveat)).to.be.false 72 | 73 | // check that it will return the value of a newer caveat with the same condition 74 | const newerCaveat = new Caveat({ condition, value: value - 1 }) 75 | macaroon.addFirstPartyCaveat(newerCaveat.encode()) 76 | const macb642 = getRawMacaroon(macaroon) 77 | expect(hasCaveat(macb642, newerCaveat)).to.equal( 78 | newerCaveat.value && newerCaveat.value.toString() 79 | ) 80 | }) 81 | 82 | it('should throw for an incorrectly encoded caveat', () => { 83 | const macaroon = Macaroon.newMacaroon({ 84 | version: 1, 85 | rootKey: 'secret', 86 | identifier: 'pubId', 87 | location: 'location', 88 | }) 89 | 90 | const macb643 = getRawMacaroon(macaroon) 91 | 92 | const test = (): boolean | ErrInvalidCaveat | string => 93 | hasCaveat(macb643, 'condition:fail') 94 | 95 | expect(test).to.throw(ErrInvalidCaveat) 96 | }) 97 | }) 98 | 99 | describe('verifyCaveats', () => { 100 | let caveat1: Caveat, 101 | caveat2: Caveat, 102 | caveat3: Caveat, 103 | satisfier: Satisfier, 104 | caveats: Caveat[] 105 | 106 | beforeEach(() => { 107 | caveat1 = new Caveat({ condition: '1', value: 'test' }) 108 | caveat2 = new Caveat({ condition: '1', value: 'test2' }) 109 | caveat3 = new Caveat({ condition: '3', value: 'foobar' }) 110 | caveats = [caveat1, caveat2, caveat3] 111 | 112 | satisfier = { 113 | condition: caveat1.condition, 114 | // dummy satisfyPrevious function to test that it tests caveat lists correctly 115 | satisfyPrevious: (prev, cur): boolean => 116 | prev.value.toString().includes('test') && 117 | cur.value.toString().includes('test'), 118 | satisfyFinal: (): boolean => true, 119 | } 120 | }) 121 | it('should verify caveats given a set of satisfiers', () => { 122 | const validatesCaveats = (): boolean => verifyCaveats(caveats, satisfier) 123 | 124 | expect(validatesCaveats).to.not.throw() 125 | expect(validatesCaveats()).to.be.true 126 | }) 127 | 128 | it('should throw when satisfiers fail', () => { 129 | const invalidSatisfyFinal: Satisfier = { 130 | ...satisfier, 131 | satisfyFinal: (): boolean => false, 132 | } 133 | const invalidSatisfyPrev: Satisfier = { 134 | ...satisfier, 135 | // dummy satisfyPrevious function to test that it tests caveat lists correctly 136 | satisfyPrevious: (prev, cur): boolean => 137 | prev.value.toString().includes('test') && 138 | cur.value.toString().includes('foobar'), 139 | } 140 | const invalidSatisifers1 = [satisfier, invalidSatisfyFinal] 141 | const invalidSatisifers2 = [satisfier, invalidSatisfyPrev] 142 | const invalidateFinal = (): boolean => 143 | verifyCaveats(caveats, invalidSatisifers1) 144 | const invalidatePrev = (): boolean => 145 | verifyCaveats(caveats, invalidSatisifers2) 146 | 147 | expect(invalidateFinal()).to.be.false 148 | expect(invalidatePrev()).to.be.false 149 | }) 150 | 151 | it('should be able to use an options object for verification', () => { 152 | const testCaveat = new Caveat({ 153 | condition: 'middlename', 154 | value: 'danger', 155 | }) 156 | caveats.push(testCaveat) 157 | satisfier = { 158 | condition: testCaveat.condition, 159 | // dummy satisfyPrevious function to test that it tests caveat lists correctly 160 | satisfyPrevious: (prev, cur, options): boolean => 161 | prev.value.toString().includes('test') && 162 | cur.value.toString().includes('test') && 163 | options.body.middlename === testCaveat.value, 164 | satisfyFinal: (caveat, options): boolean => { 165 | if ( 166 | caveat.condition === testCaveat.condition && 167 | options.body.middlename === testCaveat.value 168 | ) 169 | return true 170 | 171 | return false 172 | }, 173 | } 174 | 175 | let isValid = verifyCaveats(caveats, satisfier, { 176 | body: { middlename: 'bob' }, 177 | }) 178 | 179 | expect(isValid, 'should fail when given an invalid options object').to.be 180 | .false 181 | 182 | isValid = verifyCaveats(caveats, satisfier, { 183 | body: { middlename: testCaveat.value }, 184 | }) 185 | 186 | expect(isValid, 'should pass when given a valid options object').to.be 187 | .true 188 | }) 189 | }) 190 | }) 191 | -------------------------------------------------------------------------------- /tests/data.ts: -------------------------------------------------------------------------------- 1 | export const invoice = { 2 | payreq: 3 | 'lntb10u1pw7kfm8pp50nhe8uk9r2n9yz97c9z8lsu0ckxehnsnwkjn9mdsmnf' + 4 | 'fpgkrxzhqdq5w3jhxapqd9h8vmmfvdjscqzpgllq2qvdlgkllc27kpd87lz8p' + 5 | 'dfsfmtteyc3kwq734jpwnvqt96e4nuy0yauzdrtkumxsvawgda8dlljxu3nnj' + 6 | 'lhs6w75390wy7ukj6cpfmygah', 7 | secret: '2ca931a1c36b48f54948b898a271a53ed91ff7d0081939a5fa511249e81cba5c', 8 | paymentHash: 9 | '7cef93f2c51aa65208bec1447fc38fc58d9bce1375a532edb0dcd290a2c330ae', 10 | } 11 | 12 | export const testChallengeParts = [ 13 | { 14 | testName: 'macaroon with padding', 15 | challenge: `macaroon="AgESMy4xMzYuMTc4LjE1OjM0MjM4AkIAAD2b0rX78LATiVo8bKgHuurefeF5OeX2H5ZuacBIK3+RAR1PKU1oZpfCZFib4zdDoj0pOpgPmhtuzNllU+y//D0AAAYgcWFs9FIteCzpCcEPSwmXKBpcx97hyL5Yt99cbLjRHzU="`, 16 | expectedValue: 17 | 'AgESMy4xMzYuMTc4LjE1OjM0MjM4AkIAAD2b0rX78LATiVo8bKgHuurefeF5OeX2H5ZuacBIK3+RAR1PKU1oZpfCZFib4zdDoj0pOpgPmhtuzNllU+y//D0AAAYgcWFs9FIteCzpCcEPSwmXKBpcx97hyL5Yt99cbLjRHzU=', 18 | }, 19 | { 20 | testName: 'invoice challenge', 21 | challenge: `invoice="lntb20n1psza5dwpp58kda9d0m7zcp8z2683k2spa6at08mcte88jlv8ukde5uqjpt07gsdzjfp85gnpqd9h8vmmfvdjjqurp09kk2mn5ypn8ymmdyppksctfdecx76twwssyxmmjv5sxcmny8gcnqvps8ycqzpgsp5m7xru8dlhrhmwjp8gynsj2l9mwan2jk52ah5xucrn9kc3p0pj5ns9qy9qsq7jjxypyyc7hvvs8srh6c3lvcp5l5wka94htnfxak99hd5qrx69sya9sj4zm3w5lncw0tksf944q73tduhlhs5apd63m9dte9dhva5dgqaceunx"`, 22 | expectedValue: `lntb20n1psza5dwpp58kda9d0m7zcp8z2683k2spa6at08mcte88jlv8ukde5uqjpt07gsdzjfp85gnpqd9h8vmmfvdjjqurp09kk2mn5ypn8ymmdyppksctfdecx76twwssyxmmjv5sxcmny8gcnqvps8ycqzpgsp5m7xru8dlhrhmwjp8gynsj2l9mwan2jk52ah5xucrn9kc3p0pj5ns9qy9qsq7jjxypyyc7hvvs8srh6c3lvcp5l5wka94htnfxak99hd5qrx69sya9sj4zm3w5lncw0tksf944q73tduhlhs5apd63m9dte9dhva5dgqaceunx`, 23 | }, 24 | ] 25 | 26 | export const aperture = { 27 | challenge: 28 | 'LSAT macaroon="AgEEbHNhdAJCAAAwpHpumws6ufQoDwiTLNcge0QPUIWA0+tVY+tKPYAJ/zSfmEGlIpNm3VzxuzCqLhEp5KGiyPLUM9L+kcB7uzS+AAIPc2VydmljZXM9bWVtZTowAAISbWVtZV9jYXBhYmlsaXRpZXM9AAAGILA1VCEIExukt4nG+XR9tX8WJ2BVMiHG3UNt1uYJ+NRD", invoice="lnbcrt100n1p3qqkygpp5xzj85m5mpvatnapgpuyfxtxhypa5gr6sskqd8664v04550vqp8lsdq8f3f5z4qcqzpgsp5tpzvsq5pckqgln3ltpy3e9cf6tf2aj82ffa2tted77ltweuaweks9qyyssq0w5da3k40wtdukac7hp5s58hsxf8k8f52c5qneezu4xg3xh87xrnkl5jwtw098d2gjywx20nmkxl2y4vq9fr89kg5kzcwupv9xpdaggp4fc4ms"', 29 | macaroon: 30 | 'AgEEbHNhdAJCAAAwpHpumws6ufQoDwiTLNcge0QPUIWA0+tVY+tKPYAJ/zSfmEGlIpNm3VzxuzCqLhEp5KGiyPLUM9L+kcB7uzS+AAIPc2VydmljZXM9bWVtZTowAAISbWVtZV9jYXBhYmlsaXRpZXM9AAAGILA1VCEIExukt4nG+XR9tX8WJ2BVMiHG3UNt1uYJ+NRD', 31 | } 32 | 33 | export const testChallenges = [ 34 | { 35 | name: 'aperture originated challenge', 36 | challenge: 37 | 'macaroon="AgEEbHNhdAJCAAAwpHpumws6ufQoDwiTLNcge0QPUIWA0+tVY+tKPYAJ/zSfmEGlIpNm3VzxuzCqLhEp5KGiyPLUM9L+kcB7uzS+AAIPc2VydmljZXM9bWVtZTowAAISbWVtZV9jYXBhYmlsaXRpZXM9AAAGILA1VCEIExukt4nG+XR9tX8WJ2BVMiHG3UNt1uYJ+NRD", invoice="lnbcrt100n1p3qqkygpp5xzj85m5mpvatnapgpuyfxtxhypa5gr6sskqd8664v04550vqp8lsdq8f3f5z4qcqzpgsp5tpzvsq5pckqgln3ltpy3e9cf6tf2aj82ffa2tted77ltweuaweks9qyyssq0w5da3k40wtdukac7hp5s58hsxf8k8f52c5qneezu4xg3xh87xrnkl5jwtw098d2gjywx20nmkxl2y4vq9fr89kg5kzcwupv9xpdaggp4fc4ms"', 38 | paymentHash: 39 | '30a47a6e9b0b3ab9f4280f08932cd7207b440f508580d3eb5563eb4a3d8009ff', 40 | macaroon: 41 | 'AgEEbHNhdAJCAAAwpHpumws6ufQoDwiTLNcge0QPUIWA0+tVY+tKPYAJ/zSfmEGlIpNm3VzxuzCqLhEp5KGiyPLUM9L+kcB7uzS+AAIPc2VydmljZXM9bWVtZTowAAISbWVtZV9jYXBhYmlsaXRpZXM9AAAGILA1VCEIExukt4nG+XR9tX8WJ2BVMiHG3UNt1uYJ+NRD', 42 | }, 43 | { 44 | name: 'challenge with space', 45 | expiration: 1644194263693, 46 | paymentHash: 47 | '7cef93f2c51aa65208bec1447fc38fc58d9bce1375a532edb0dcd290a2c330ae', 48 | macaroon: 49 | 'AgEIbG9jYXRpb24ChAEwMDAwN2NlZjkzZjJjNTFhYTY1MjA4YmVjMTQ0N2ZjMzhmYzU4ZDliY2UxMzc1YTUzMmVkYjBkY2QyOTBhMmMzMzBhZTljNTMyNjZiMWJlMzE1MGI2NjZiM2Y2ZWM3MGYyOGJkNDNhOWQ0ZmQxOTQ2MWE2MjBmYmFjYzMzNzY4YTk5OTQAAhhleHBpcmF0aW9uPTE2NDQxOTQyNjM2OTMAAAYg_1CMq8TuMXv-ERHDWQCtlIhsQwwKiUDmnnh1maDFpkQ', 50 | challenge: 51 | 'macaroon="AgEIbG9jYXRpb24ChAEwMDAwN2NlZjkzZjJjNTFhYTY1MjA4YmVjMTQ0N2ZjMzhmYzU4ZDliY2UxMzc1YTUzMmVkYjBkY2QyOTBhMmMzMzBhZTljNTMyNjZiMWJlMzE1MGI2NjZiM2Y2ZWM3MGYyOGJkNDNhOWQ0ZmQxOTQ2MWE2MjBmYmFjYzMzNzY4YTk5OTQAAhhleHBpcmF0aW9uPTE2NDQxOTQyNjM2OTMAAAYg_1CMq8TuMXv-ERHDWQCtlIhsQwwKiUDmnnh1maDFpkQ" invoice="lntb10u1pw7kfm8pp50nhe8uk9r2n9yz97c9z8lsu0ckxehnsnwkjn9mdsmnffpgkrxzhqdq5w3jhxapqd9h8vmmfvdjscqzpgllq2qvdlgkllc27kpd87lz8pdfsfmtteyc3kwq734jpwnvqt96e4nuy0yauzdrtkumxsvawgda8dlljxu3nnjlhs6w75390wy7ukj6cpfmygah"', 52 | }, 53 | { 54 | name: 'challenge without space', 55 | expiration: 1644194263693, 56 | paymentHash: 57 | '7cef93f2c51aa65208bec1447fc38fc58d9bce1375a532edb0dcd290a2c330ae', 58 | macaroon: 59 | 'AgEIbG9jYXRpb24ChAEwMDAwN2NlZjkzZjJjNTFhYTY1MjA4YmVjMTQ0N2ZjMzhmYzU4ZDliY2UxMzc1YTUzMmVkYjBkY2QyOTBhMmMzMzBhZTljNTMyNjZiMWJlMzE1MGI2NjZiM2Y2ZWM3MGYyOGJkNDNhOWQ0ZmQxOTQ2MWE2MjBmYmFjYzMzNzY4YTk5OTQAAhhleHBpcmF0aW9uPTE2NDQxOTQyNjM2OTMAAAYg_1CMq8TuMXv-ERHDWQCtlIhsQwwKiUDmnnh1maDFpkQ', 60 | challenge: 61 | 'macaroon="AgEIbG9jYXRpb24ChAEwMDAwN2NlZjkzZjJjNTFhYTY1MjA4YmVjMTQ0N2ZjMzhmYzU4ZDliY2UxMzc1YTUzMmVkYjBkY2QyOTBhMmMzMzBhZTljNTMyNjZiMWJlMzE1MGI2NjZiM2Y2ZWM3MGYyOGJkNDNhOWQ0ZmQxOTQ2MWE2MjBmYmFjYzMzNzY4YTk5OTQAAhhleHBpcmF0aW9uPTE2NDQxOTQyNjM2OTMAAAYg_1CMq8TuMXv-ERHDWQCtlIhsQwwKiUDmnnh1maDFpkQ",invoice="lntb10u1pw7kfm8pp50nhe8uk9r2n9yz97c9z8lsu0ckxehnsnwkjn9mdsmnffpgkrxzhqdq5w3jhxapqd9h8vmmfvdjscqzpgllq2qvdlgkllc27kpd87lz8pdfsfmtteyc3kwq734jpwnvqt96e4nuy0yauzdrtkumxsvawgda8dlljxu3nnjlhs6w75390wy7ukj6cpfmygah"', 62 | }, 63 | { 64 | name: 'challenge with golang generated macaroon', 65 | paymentHash: 66 | '3d9bd2b5fbf0b013895a3c6ca807baeade7de17939e5f61f966e69c0482b7f91', 67 | macaroon: 68 | 'AgESMy4xMzYuMTc4LjE1OjM0MjM4AkIAAD2b0rX78LATiVo8bKgHuurefeF5OeX2H5ZuacBIK3+RAR1PKU1oZpfCZFib4zdDoj0pOpgPmhtuzNllU+y//D0AAAYgcWFs9FIteCzpCcEPSwmXKBpcx97hyL5Yt99cbLjRHzU=', 69 | challenge: 70 | 'macaroon="AgESMy4xMzYuMTc4LjE1OjM0MjM4AkIAAD2b0rX78LATiVo8bKgHuurefeF5OeX2H5ZuacBIK3+RAR1PKU1oZpfCZFib4zdDoj0pOpgPmhtuzNllU+y//D0AAAYgcWFs9FIteCzpCcEPSwmXKBpcx97hyL5Yt99cbLjRHzU=", invoice="lntb20n1psza5dwpp58kda9d0m7zcp8z2683k2spa6at08mcte88jlv8ukde5uqjpt07gsdzjfp85gnpqd9h8vmmfvdjjqurp09kk2mn5ypn8ymmdyppksctfdecx76twwssyxmmjv5sxcmny8gcnqvps8ycqzpgsp5m7xru8dlhrhmwjp8gynsj2l9mwan2jk52ah5xucrn9kc3p0pj5ns9qy9qsq7jjxypyyc7hvvs8srh6c3lvcp5l5wka94htnfxak99hd5qrx69sya9sj4zm3w5lncw0tksf944q73tduhlhs5apd63m9dte9dhva5dgqaceunx"', 71 | }, 72 | ] 73 | 74 | export const testChallengeErrors = [ 75 | { 76 | name: 'missing invoice', 77 | challenge: 78 | 'macaroon=AgEIbG9jYXRpb24ChAEwMDAwN2NlZjkzZjJjNTFhYTY1MjA4YmVjMTQ0N2ZjMzhmYzU4ZDliY2UxMzc1YTUzMmVkYjBkY2QyOTBhMmMzMzBhZTljNTMyNjZiMWJlMzE1MGI2NjZiM2Y2ZWM3MGYyOGJkNDNhOWQ0ZmQxOTQ2MWE2MjBmYmFjYzMzNzY4YTk5OTQAAhhleHBpcmF0aW9uPTE2NDQxOTQyNjM2OTMAAAYg_1CMq8TuMXv-ERHDWQCtlIhsQwwKiUDmnnh1maDFpkQ', 79 | error: 'Expected at least two challenges in the LSAT: invoice and macaroon', 80 | }, 81 | { 82 | name: 'missing macaroon', 83 | challenge: 84 | 'invoice="lntb10u1pw7kfm8pp50nhe8uk9r2n9yz97c9z8lsu0ckxehnsnwkjn9mdsmnffpgkrxzhqdq5w3jhxapqd9h8vmmfvdjscqzpgllq2qvdlgkllc27kpd87lz8pdfsfmtteyc3kwq734jpwnvqt96e4nuy0yauzdrtkumxsvawgda8dlljxu3nnjlhs6w75390wy7ukj6cpfmygah"', 85 | error: 'Expected at least two challenges in the LSAT: invoice and macaroon', 86 | }, 87 | { 88 | name: 'macaroon not in double quotes', 89 | challenge: 90 | 'macaroon=AgEIbG9jYXRpb24ChAEwMDAwN2NlZjkzZjJjNTFhYTY1MjA4YmVjMTQ0N2ZjMzhmYzU4ZDliY2UxMzc1YTUzMmVkYjBkY2QyOTBhMmMzMzBhZWFkMDE0MmNlMjcxYjY5OTkzNDY5NDZlYzBlYTg1NmEwZTg4Zjc1YTE5YTZkMGMwNWVhMzZhNTVjY2E1MjYwYzAAAhhleHBpcmF0aW9uPTE2NDQxOTYyMDkwMzkAAAYgC0wqY_xSoouOLRuYipfQAu_HeSSVUDfgkro9Mg6AnHc, invoice="lntb10u1pw7kfm8pp50nhe8uk9r2n9yz97c9z8lsu0ckxehnsnwkjn9mdsmnffpgkrxzhqdq5w3jhxapqd9h8vmmfvdjscqzpgllq2qvdlgkllc27kpd87lz8pdfsfmtteyc3kwq734jpwnvqt96e4nuy0yauzdrtkumxsvawgda8dlljxu3nnjlhs6w75390wy7ukj6cpfmygah"', 91 | error: 92 | 'Incorectly encoded challenge, challenges must be enclosed in double quotes', 93 | }, 94 | { 95 | name: 'invoice not in double quotes', 96 | challenge: 97 | 'macaroon="AgEIbG9jYXRpb24ChAEwMDAwN2NlZjkzZjJjNTFhYTY1MjA4YmVjMTQ0N2ZjMzhmYzU4ZDliY2UxMzc1YTUzMmVkYjBkY2QyOTBhMmMzMzBhZWFkMDE0MmNlMjcxYjY5OTkzNDY5NDZlYzBlYTg1NmEwZTg4Zjc1YTE5YTZkMGMwNWVhMzZhNTVjY2E1MjYwYzAAAhhleHBpcmF0aW9uPTE2NDQxOTYyMDkwMzkAAAYgC0wqY_xSoouOLRuYipfQAu_HeSSVUDfgkro9Mg6AnHc" invoice=lntb10u1pw7kfm8pp50nhe8uk9r2n9yz97c9z8lsu0ckxehnsnwkjn9mdsmnffpgkrxzhqdq5w3jhxapqd9h8vmmfvdjscqzpgllq2qvdlgkllc27kpd87lz8pdfsfmtteyc3kwq734jpwnvqt96e4nuy0yauzdrtkumxsvawgda8dlljxu3nnjlhs6w75390wy7ukj6cpfmygah', 98 | error: 99 | 'Incorectly encoded challenge, challenges must be enclosed in double quotes', 100 | }, 101 | { 102 | name: 'neither part in double quotes', 103 | challenge: 104 | 'macaroon=AgEIbG9jYXRpb24ChAEwMDAwN2NlZjkzZjJjNTFhYTY1MjA4YmVjMTQ0N2ZjMzhmYzU4ZDliY2UxMzc1YTUzMmVkYjBkY2QyOTBhMmMzMzBhZWFkMDE0MmNlMjcxYjY5OTkzNDY5NDZlYzBlYTg1NmEwZTg4Zjc1YTE5YTZkMGMwNWVhMzZhNTVjY2E1MjYwYzAAAhhleHBpcmF0aW9uPTE2NDQxOTYyMDkwMzkAAAYgC0wqY_xSoouOLRuYipfQAu_HeSSVUDfgkro9Mg6AnHc invoice=lntb10u1pw7kfm8pp50nhe8uk9r2n9yz97c9z8lsu0ckxehnsnwkjn9mdsmnffpgkrxzhqdq5w3jhxapqd9h8vmmfvdjscqzpgllq2qvdlgkllc27kpd87lz8pdfsfmtteyc3kwq734jpwnvqt96e4nuy0yauzdrtkumxsvawgda8dlljxu3nnjlhs6w75390wy7ukj6cpfmygah', 105 | error: 106 | 'Incorectly encoded challenge, challenges must be enclosed in double quotes', 107 | }, 108 | ] 109 | -------------------------------------------------------------------------------- /tests/helpers.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { getIdFromRequest } from '../src/helpers' 3 | import { invoice } from './data' 4 | 5 | describe('helpers', () => { 6 | describe('getIdFromRequest', () => { 7 | it('should return the correct paymentHash from lightning invoice', () => { 8 | const actual = getIdFromRequest(invoice.payreq) 9 | expect(actual).to.equal(invoice.paymentHash) 10 | }) 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /tests/identifier.spec.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from 'crypto' 2 | import { expect } from 'chai' 3 | 4 | import { 5 | Identifier, 6 | LATEST_VERSION, 7 | TOKEN_ID_SIZE, 8 | ErrUnknownVersion, 9 | decodeIdentifierFromMacaroon, 10 | } from '../src' 11 | import { testChallenges } from './data' 12 | 13 | describe('Macaroon Identifier', () => { 14 | it('should properly serialize identifier of known version', () => { 15 | const options = { 16 | version: LATEST_VERSION, 17 | paymentHash: randomBytes(32), 18 | tokenId: randomBytes(TOKEN_ID_SIZE), 19 | } 20 | 21 | const identifier = new Identifier(options) 22 | const encodeId = (): Buffer => identifier.encode() 23 | expect(encodeId).to.not.throw() 24 | const decoded = Identifier.decode(identifier.encode()) 25 | expect(decoded).to.deep.equal(options) 26 | }) 27 | 28 | it('should fail for unknown identifier version', () => { 29 | const options = { 30 | version: LATEST_VERSION + 1, 31 | paymentHash: randomBytes(32), 32 | tokenId: randomBytes(TOKEN_ID_SIZE), 33 | } 34 | 35 | const encodeId = (): Identifier => new Identifier(options) 36 | expect(encodeId).to.throw(ErrUnknownVersion, options.version.toString()) 37 | }) 38 | 39 | it('can decode from different macaroon types', () => { 40 | for (const { macaroon } of testChallenges) { 41 | const id = decodeIdentifierFromMacaroon(macaroon) 42 | Identifier.fromString(id) 43 | } 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /tests/lsat.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import * as Macaroon from 'macaroon' 4 | import { 5 | Caveat, 6 | getRawMacaroon, 7 | Lsat, 8 | parseChallengePart, 9 | } from '../src' 10 | import { 11 | testChallengeParts, 12 | invoice, 13 | testChallenges, 14 | testChallengeErrors, 15 | } from './data' 16 | import { getTestBuilder } from './utilities' 17 | import { decode } from '../src/helpers' 18 | 19 | describe('parseChallengePart', () => { 20 | it('should handle macaroon with base64 padding', () => { 21 | for (const challenge of testChallengeParts) { 22 | expect(() => parseChallengePart(challenge.challenge)).not.to.throw() 23 | expect(parseChallengePart(challenge.challenge)).to.equal( 24 | challenge.expectedValue 25 | ) 26 | } 27 | }) 28 | }) 29 | 30 | describe('LSAT Token', () => { 31 | let macaroon: string, 32 | paymentPreimage: string, 33 | expiration: number, 34 | challenge: string 35 | 36 | beforeEach(() => { 37 | expiration = Date.now() + 1000 38 | const caveat = new Caveat({ condition: 'expiration', value: expiration }) 39 | 40 | paymentPreimage = invoice.secret 41 | 42 | const builder = getTestBuilder('secret') 43 | builder.addFirstPartyCaveat(caveat.encode()) 44 | 45 | const macb64 = getRawMacaroon(builder) 46 | macaroon = macb64 47 | challenge = `macaroon="${macb64}", invoice="${invoice.payreq}"` 48 | }) 49 | 50 | it('should be able to decode lsat challenges', () => { 51 | for (const { 52 | name, 53 | challenge, 54 | macaroon, 55 | paymentHash, 56 | expiration, 57 | } of testChallenges) { 58 | const fromChallenge = (): Lsat => Lsat.fromChallenge(challenge) 59 | 60 | expect(fromChallenge, `${name} should not have thrown`).to.not.throw() 61 | const lsat = fromChallenge() 62 | expect(lsat.baseMacaroon).to.equal( 63 | macaroon, 64 | `macaroon from ${name} LSAT did not match` 65 | ) 66 | expect(lsat.paymentHash).to.equal( 67 | paymentHash, 68 | `paymentHash from ${name} LSAT did not match` 69 | ) 70 | if (expiration) 71 | expect(lsat.validUntil).to.equal( 72 | expiration, 73 | `expiration from ${name} LSAT did not match` 74 | ) 75 | else expect(lsat.validUntil).to.equal(0) 76 | } 77 | }) 78 | 79 | it('should be able to decode header challenges', () => { 80 | for (const { name, challenge } of testChallenges) { 81 | const header = `LSAT ${challenge}` 82 | const fromHeader = (): Lsat => Lsat.fromHeader(header) 83 | expect(fromHeader, `${name} should not have thrown`).to.not.throw() 84 | } 85 | }) 86 | 87 | it('should fail on incorrectly encoded challenges', () => { 88 | for (const { name, challenge, error } of testChallengeErrors) { 89 | const fromChallenge = (): Lsat => Lsat.fromChallenge(challenge) 90 | expect(fromChallenge, `${name} should not have thrown`).to.throw(error) 91 | } 92 | }) 93 | 94 | it('should be able to check expiration to see if expired', () => { 95 | const lsat = Lsat.fromChallenge(challenge) 96 | expect(lsat.isExpired()).to.be.false 97 | }) 98 | 99 | it('should check if payment is pending', () => { 100 | const lsat = Lsat.fromChallenge(challenge) 101 | 102 | expect(lsat).to.have.property('isPending') 103 | expect(lsat.isPending()).to.be.true 104 | }) 105 | 106 | it('should be able to add valid preimage', () => { 107 | const lsat = Lsat.fromChallenge(challenge) 108 | 109 | const addWrongPreimage = (): void => 110 | lsat.setPreimage(Buffer.alloc(32, 'a').toString('hex')) 111 | const addIncorrectLength = (): void => lsat.setPreimage('abcde12345') 112 | const addNonHex = (): void => lsat.setPreimage('xyzNMOP') 113 | expect(addWrongPreimage).to.throw('did not match') 114 | expect(addIncorrectLength).to.throw('32-byte hash') 115 | expect(addNonHex).to.throw('32-byte hash') 116 | 117 | const addSecret = (): void => lsat.setPreimage(paymentPreimage) 118 | expect(addSecret).to.not.throw() 119 | expect(lsat.paymentPreimage).to.equal(paymentPreimage) 120 | }) 121 | 122 | it('should be able to return an LSAT token string', () => { 123 | const lsat = Lsat.fromChallenge(challenge) 124 | 125 | lsat.setPreimage(paymentPreimage) 126 | 127 | const expectedToken = `LSAT ${macaroon}:${paymentPreimage}` 128 | const token = lsat.toToken() 129 | 130 | expect(token).to.equal(expectedToken) 131 | }) 132 | 133 | it('should be able to decode from token', () => { 134 | let token = `LSAT ${macaroon}:${paymentPreimage}` 135 | let lsat = Lsat.fromToken(token) 136 | expect(lsat.baseMacaroon).to.equal(macaroon) 137 | expect(lsat.paymentPreimage).to.equal(paymentPreimage) 138 | expect(lsat.toToken()).to.equal(token) 139 | 140 | // test with no secret 141 | token = `LSAT ${macaroon}:` 142 | lsat = Lsat.fromToken(token) 143 | expect(lsat.baseMacaroon).to.equal(macaroon) 144 | expect(!lsat.paymentPreimage).to.be.true 145 | expect(lsat.toToken()).to.equal(token) 146 | }) 147 | 148 | it('should be able to add a first party caveat to the macaroon', () => { 149 | const lsat = Lsat.fromChallenge(challenge) 150 | const newCaveat = new Caveat({ 151 | condition: 'expiration', 152 | value: expiration / 2, 153 | }) 154 | 155 | const rawOriginal = lsat.baseMacaroon 156 | lsat.addFirstPartyCaveat(newCaveat) 157 | const rawMac = lsat.baseMacaroon 158 | 159 | expect(rawMac).to.not.equal( 160 | rawOriginal, 161 | "LSAT's base macaroon should be updated" 162 | ) 163 | 164 | const originalMac = Macaroon.importMacaroon(rawOriginal) 165 | const mac = Macaroon.importMacaroon(rawMac) 166 | const originalcavs = originalMac._exportAsJSONObjectV2().c 167 | const cavs = mac._exportAsJSONObjectV2().c 168 | if (cavs == undefined || originalcavs == undefined) { 169 | return 170 | } 171 | expect(cavs.length).to.equal( 172 | originalcavs.length + 1, 173 | 'new macaroon should have one more caveat than the original' 174 | ) 175 | 176 | expect(lsat.getExpirationFromMacaroon()).to.equal(newCaveat.value) 177 | }) 178 | 179 | it('should be able to return a list of caveats from the macaroon', () => { 180 | const lsat = Lsat.fromChallenge(challenge) 181 | 182 | const ogCount = lsat.getCaveats().length 183 | const firstCaveat = new Caveat({ condition: 'name', value: 'john snow' }) 184 | const secondCaveat = new Caveat({ 185 | condition: 'number', 186 | comp: '<', 187 | value: 4, 188 | }) 189 | const newCaveats = [firstCaveat, secondCaveat] 190 | 191 | for (const c of newCaveats) { 192 | lsat.addFirstPartyCaveat(c) 193 | } 194 | 195 | let caveats = lsat.getCaveats() 196 | expect(caveats.length).to.equal( 197 | ogCount + newCaveats.length, 198 | `should have ${newCaveats.length} more caveats on base macaroon` 199 | ) 200 | 201 | // test that the caveats match and are added in order 202 | for (let i = 0; i < newCaveats.length; i++) { 203 | caveats = caveats.slice(-newCaveats.length) 204 | expect(newCaveats[i].condition).to.equal(caveats[i].condition) 205 | expect(newCaveats[i].value == caveats[i].value).to.be.true 206 | expect(newCaveats[i].comp).to.equal(caveats[i].comp) 207 | } 208 | }) 209 | 210 | it('should be able to determine if an LSAT is satisfied or not', () => { 211 | const lsat = Lsat.fromChallenge(challenge) 212 | expect(lsat.isSatisfied()).to.be.false 213 | lsat.paymentPreimage = '12345' 214 | expect(lsat.isSatisfied()).to.be.false 215 | lsat.setPreimage(paymentPreimage) 216 | expect(lsat.isSatisfied()).to.be.true 217 | }) 218 | 219 | it('should be able to generate an LSAT from a macaroon and invoice', () => { 220 | let lsat = Lsat.fromMacaroon(macaroon) 221 | 222 | expect(lsat.baseMacaroon).to.equal(macaroon) 223 | 224 | lsat = Lsat.fromMacaroon(macaroon, invoice.payreq) 225 | expect(lsat.baseMacaroon).to.equal(macaroon) 226 | expect(lsat.invoice).to.equal(invoice.payreq) 227 | const invAmount = decode(invoice.payreq).satoshis || 0 228 | expect(lsat.invoiceAmount).to.equal(+invAmount) 229 | }) 230 | 231 | it('should be able to add an invoice', () => { 232 | const lsat = Lsat.fromMacaroon(macaroon) 233 | const invAmount = decode(invoice.payreq).satoshis || 0 234 | 235 | lsat.addInvoice(invoice.payreq) 236 | 237 | expect(lsat.invoice).to.equal(invoice.payreq) 238 | expect(lsat.invoiceAmount).to.equal(invAmount) 239 | 240 | const addInvalidInv = (): void => lsat.addInvoice('12345') 241 | expect(addInvalidInv).to.throw() 242 | }) 243 | 244 | it('test macaroon versions', () => { 245 | for (const { macaroon, name } of testChallenges) { 246 | const test = () => Lsat.fromMacaroon(macaroon) 247 | expect(test, `${name} should not have thrown`).to.not.throw() 248 | } 249 | }) 250 | }) 251 | -------------------------------------------------------------------------------- /tests/macaroon.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Macaroon from 'macaroon' 3 | import * as caveat from '../src/caveat' 4 | import { 5 | Caveat, 6 | getCaveatsFromMacaroon, 7 | getRawMacaroon, 8 | MacaroonClass, 9 | Satisfier, 10 | verifyMacaroonCaveats, 11 | } from '../src' 12 | import sinon from 'sinon' 13 | 14 | describe('macaroon', () => { 15 | let caveat1: Caveat, 16 | caveat2: Caveat, 17 | caveat3: Caveat, 18 | satisfier: Satisfier, 19 | mac: MacaroonClass, 20 | secret: string 21 | 22 | beforeEach(() => { 23 | caveat1 = new Caveat({ condition: '1', value: 'test' }) 24 | caveat2 = new Caveat({ condition: '1', value: 'test2' }) 25 | caveat3 = new Caveat({ condition: '3', value: 'foobar' }) 26 | 27 | satisfier = { 28 | condition: caveat1.condition, 29 | // dummy satisfyPrevious function to test that it tests caveat lists correctly 30 | satisfyPrevious: (prev, cur): boolean => 31 | prev.value.toString().includes('test') && 32 | cur.value.toString().includes('test'), 33 | satisfyFinal: (): boolean => true, 34 | } 35 | secret = 'secret' 36 | mac = Macaroon.newMacaroon({ 37 | version: 1, 38 | rootKey: secret, 39 | identifier: 'pubId', 40 | location: 'location', 41 | }) 42 | }) 43 | 44 | describe('getCaveatsFromMacaroon', () => { 45 | it('should correctly return all caveats from raw macaroon', () => { 46 | const testCaveats = [caveat1, caveat2, caveat3] 47 | testCaveats.forEach(c => mac.addFirstPartyCaveat(c.encode())) 48 | const raw = getRawMacaroon(mac) 49 | const caveats = getCaveatsFromMacaroon(raw) 50 | expect(caveats).to.have.lengthOf(testCaveats.length) 51 | }) 52 | 53 | it('should return empty array if no caveats', () => { 54 | const raw = getRawMacaroon(mac) 55 | const caveats = getCaveatsFromMacaroon(raw) 56 | expect(caveats).to.have.lengthOf(0) 57 | }) 58 | }) 59 | 60 | describe('verifyMacaroonCaveats', () => { 61 | it('should verify the signature on a macaroon w/ no caveats', () => { 62 | const isValid = verifyMacaroonCaveats( 63 | getRawMacaroon(mac), 64 | secret, 65 | satisfier 66 | ) 67 | expect(isValid).to.be.true 68 | }) 69 | 70 | it('should run verifyCaveats with all caveats and satisfiers', () => { 71 | const testCaveats = [caveat1, caveat2] 72 | testCaveats.forEach(c => mac.addFirstPartyCaveat(c.encode())) 73 | const spy = sinon.spy(caveat, 'verifyCaveats') 74 | const isValid = verifyMacaroonCaveats( 75 | getRawMacaroon(mac), 76 | secret, 77 | satisfier 78 | ) 79 | expect(isValid).to.be.true 80 | expect(spy.calledWithMatch(testCaveats, [satisfier])).to.be.true 81 | }) 82 | 83 | it('should return false if caveats dont verify', () => { 84 | satisfier.satisfyFinal = (): boolean => false 85 | mac.addFirstPartyCaveat(caveat1.encode()) 86 | mac.addFirstPartyCaveat(caveat2.encode()) 87 | const isValid = verifyMacaroonCaveats( 88 | getRawMacaroon(mac), 89 | secret, 90 | satisfier 91 | ) 92 | expect(isValid).to.be.false 93 | }) 94 | }) 95 | 96 | describe('getRawMacaroon', () => { 97 | it('should convert a macaroon to base64', () => { 98 | // built-in Macaroon function doesn't handle slashes properly 99 | // so want to test with one that has them 100 | const original = 101 | 'AgEEbHNhdAJCAACpIed4t8z8YTUNlAsUMqi1cYNA0kTYT6ajy0FpYySS/c/Lpm7rxB1Qyskte0aSEf3Ze2buI3yl2wmZtVgMZjzVAAIWc2VydmljZXM9c3BoaW54X21lbWU6MAACJXNwaGlueF9tZW1lX2NhcGFiaWxpdGllcz1sYXJnZV91cGxvYWQAAhZsYXJnZV91cGxvYWRfbWF4X21iPTMyAAAGIEFPRpVD8ryeKlJsfMvtufUogBiUwvz/h9KP/FC6gHg8' 102 | 103 | const mac = Macaroon.importMacaroon(original) 104 | expect(getRawMacaroon(mac)).to.equal(original) 105 | }) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /tests/satisfiers.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { 3 | Caveat, 4 | expirationSatisfier, 5 | verifyCaveats, 6 | SERVICES_CAVEAT_CONDITION, 7 | InvalidServicesError, 8 | createServicesSatisfier, 9 | SERVICE_CAPABILITIES_SUFFIX, 10 | createCapabilitiesSatisfier, 11 | InvalidCapabilitiesError, 12 | } from '../src' 13 | import { Satisfier } from '../src/types' 14 | 15 | describe('satisfiers', () => { 16 | describe('expirationSatisfier', () => { 17 | let satisfier: Satisfier 18 | 19 | beforeEach(() => { 20 | satisfier = expirationSatisfier 21 | }) 22 | it('should validate expiration caveat', () => { 23 | const validCaveat = new Caveat({ 24 | condition: 'expiration', 25 | value: Date.now() + 1000, 26 | }) 27 | const expectValid = satisfier.satisfyFinal(validCaveat) 28 | const expired = new Caveat({ 29 | condition: 'expiration', 30 | value: Date.now() - 100, 31 | }) 32 | const expectFailed = satisfier.satisfyFinal(expired) 33 | 34 | expect(validCaveat.condition).to.equal(satisfier.condition) 35 | expect(expectValid, 'Valid caveat should have been satisfied').to.be.true 36 | expect(expired.condition).to.equal(satisfier.condition) 37 | expect(expectFailed, 'expired caveat should be invalid').to.be.false 38 | }) 39 | 40 | it('should only satisfy caveats that get more restrictive', () => { 41 | const interval = 1000 42 | const condition = 'expiration' 43 | const firstCaveat = new Caveat({ 44 | condition, 45 | value: Date.now() + interval, 46 | }) 47 | const secondCaveat = new Caveat({ 48 | condition, 49 | value: Date.now() + interval / 2, // more restrictive time 50 | }) 51 | const expectValid = verifyCaveats([firstCaveat, secondCaveat], satisfier) 52 | const expectFailed = verifyCaveats([secondCaveat, firstCaveat], satisfier) 53 | 54 | expect(satisfier).to.have.property('satisfyPrevious') 55 | expect( 56 | expectValid, 57 | 'Expected caveats w/ increasing restrictiveness to pass' 58 | ).to.be.true 59 | expect( 60 | expectFailed, 61 | 'Expected caveats w/ decreasingly restrictive expirations to fail' 62 | ).to.be.false 63 | }) 64 | }) 65 | 66 | describe('services satisfier', () => { 67 | let firstCaveat: Caveat, secondCaveat: Caveat 68 | 69 | beforeEach(() => { 70 | firstCaveat = Caveat.decode(`${SERVICES_CAVEAT_CONDITION}=foo:0,bar:1`) 71 | secondCaveat = Caveat.decode(`${SERVICES_CAVEAT_CONDITION}=foo:1,bar:1`) 72 | }) 73 | 74 | const runTest = ( 75 | caveats: Caveat[], 76 | targetService: string 77 | ): boolean | Error => { 78 | const satisfier = createServicesSatisfier(targetService) 79 | return verifyCaveats(caveats, satisfier) 80 | } 81 | 82 | it('should fail to create satisfier on invalid target service', () => { 83 | const invalidTargetServices = [12, { foo: 'bar' }, ['a', 'b', 'c']] 84 | for (const target of invalidTargetServices) { 85 | // @ts-expect-error this is a test that we expect to throw 86 | expect(() => createServicesSatisfier(target)).to.throw( 87 | InvalidServicesError 88 | ) 89 | } 90 | }) 91 | 92 | it('should throw InvalidServicesError if caveats are incorrect', () => { 93 | const invalidCaveatValue = Caveat.decode( 94 | `${SERVICES_CAVEAT_CONDITION}=noTier` 95 | ) 96 | 97 | expect( 98 | () => runTest([invalidCaveatValue, firstCaveat], 'foo'), 99 | 'invalid caveat value' 100 | ).to.throw(InvalidServicesError) 101 | expect( 102 | () => runTest([firstCaveat, invalidCaveatValue], 'foo'), 103 | 'invalid caveat value' 104 | ).to.throw(InvalidServicesError) 105 | }) 106 | 107 | it('should not allow any services that were not previously allowed', () => { 108 | const invalidCaveat = Caveat.decode(`${SERVICES_CAVEAT_CONDITION}=baz:0`) 109 | const caveats = [firstCaveat, invalidCaveat] 110 | expect(runTest(caveats, 'foo')).to.be.false 111 | }) 112 | 113 | it('should validate only increasingly restrictive (higher) service tiers', () => { 114 | // order matters 115 | const caveats = [secondCaveat, firstCaveat] 116 | expect(runTest(caveats, 'foo')).to.be.false 117 | }) 118 | 119 | it('should validate for the specified target service', () => { 120 | const caveats = [firstCaveat, secondCaveat] 121 | expect(runTest(caveats, 'foo')).to.be.true 122 | expect(runTest(caveats, 'baz')).to.be.false 123 | }) 124 | }) 125 | 126 | describe('capabilities satisfier', () => { 127 | let firstCaveat: Caveat, secondCaveat: Caveat 128 | const service = 'lightning' 129 | 130 | beforeEach(() => { 131 | firstCaveat = Caveat.decode( 132 | `${service}${SERVICE_CAPABILITIES_SUFFIX}=read,write` 133 | ) 134 | secondCaveat = Caveat.decode( 135 | `${service}${SERVICE_CAPABILITIES_SUFFIX}=read` 136 | ) 137 | }) 138 | 139 | const runTest = ( 140 | caveats: Caveat[], 141 | targetCapability: string 142 | ): boolean | Error => { 143 | const satisfier = createCapabilitiesSatisfier(service, targetCapability) 144 | return verifyCaveats(caveats, satisfier) 145 | } 146 | 147 | it('should fail to create satisfier on invalid inputs', () => { 148 | const invalidInputs = [12, { foo: 'bar' }, ['a', 'b', 'c']] 149 | for (const target of invalidInputs) { 150 | // @ts-expect-error test that expects to throw 151 | expect(() => createCapabilitiesSatisfier(target, 'test')).to.throw( 152 | InvalidCapabilitiesError 153 | ) 154 | // @ts-expect-error test that expects to throw 155 | expect(() => createCapabilitiesSatisfier('test', target)).to.throw( 156 | InvalidCapabilitiesError 157 | ) 158 | } 159 | }) 160 | 161 | it('should not allow any capabilities that were not previously allowed', () => { 162 | const caveats = [secondCaveat, firstCaveat] 163 | expect(runTest(caveats, 'foo')).to.be.false 164 | }) 165 | 166 | it('should validate for the specified target capabilities', () => { 167 | const caveats = [firstCaveat, secondCaveat] 168 | expect(runTest(caveats, 'read')).to.be.true 169 | // second caveat only has read which is an attenuation with restricted permissions 170 | // so doesn't have write 171 | expect(runTest(caveats, 'write')).to.be.false 172 | // only the first caveat means it has both read and write 173 | expect(runTest([firstCaveat], 'write')).to.be.true 174 | }) 175 | }) 176 | }) 177 | -------------------------------------------------------------------------------- /tests/service.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { 3 | NoServicesError, 4 | InvalidServicesError, 5 | decodeServicesCaveat, 6 | encodeServicesCaveatValue, 7 | InvalidCapabilitiesError, 8 | createNewCapabilitiesCaveat, 9 | SERVICE_CAPABILITIES_SUFFIX, 10 | decodeCapabilitiesValue, 11 | } from '../src/service' 12 | 13 | describe('services', () => { 14 | it('can encode and decode service caveats', () => { 15 | const tests = [ 16 | { 17 | name: 'single service', 18 | value: 'a:0', 19 | }, 20 | { 21 | name: 'multiple services', 22 | value: 'a:0,b:1,c:0', 23 | }, 24 | { 25 | name: 'multiple services with spaces', 26 | value: 'a:0, b:1, c:0', 27 | }, 28 | { 29 | name: 'no services', 30 | value: '', 31 | err: NoServicesError, 32 | }, 33 | { 34 | name: 'service missing name', 35 | value: ':0', 36 | err: InvalidServicesError, 37 | }, 38 | { 39 | name: 'service missing tier', 40 | value: 'a', 41 | err: InvalidServicesError, 42 | }, 43 | { 44 | name: 'service empty tier', 45 | value: 'a:', 46 | err: InvalidServicesError, 47 | }, 48 | { 49 | name: 'service non-numeric tier', 50 | value: 'a:b', 51 | err: InvalidServicesError, 52 | }, 53 | { 54 | name: 'service non-string service name', 55 | value: '1:1', 56 | err: InvalidServicesError, 57 | }, 58 | { 59 | name: 'empty services', 60 | value: ',,', 61 | err: InvalidServicesError, 62 | }, 63 | ] 64 | 65 | for (const t of tests) { 66 | if (t.err) { 67 | expect( 68 | () => decodeServicesCaveat(t.value), 69 | `"${t.name}" did not throw expected error` 70 | ).to.throw(t.err) 71 | continue 72 | } 73 | 74 | const services = decodeServicesCaveat(t.value) 75 | // check to make typescript happy 76 | if (!(services instanceof Error)) { 77 | const rawServices = encodeServicesCaveatValue(services) 78 | expect(rawServices).to.equal(t.value) 79 | } 80 | } 81 | }) 82 | 83 | it('can create new capabilities caveat', () => { 84 | const tests = [ 85 | { 86 | name: 'valid string args', 87 | serviceName: 'foo', 88 | capabilities: 'bar,baz', 89 | }, 90 | { 91 | name: 'valid array args', 92 | serviceName: 'foo', 93 | capabilities: ['bar', 'baz'], 94 | }, 95 | { 96 | name: 'supports empty capabilities', 97 | serviceName: 'foo', 98 | capabilities: '', 99 | }, 100 | { 101 | name: 'invalid capabilities', 102 | serviceName: 'foo', 103 | capabilities: 12, 104 | err: InvalidCapabilitiesError, 105 | }, 106 | ] 107 | 108 | for (const t of tests) { 109 | if (t.err) { 110 | expect( 111 | // @ts-expect-error 112 | () => createNewCapabilitiesCaveat(t.serviceName, t.capabilities), 113 | `expected "${t.name}" to throw` 114 | ).to.throw(InvalidCapabilitiesError) 115 | continue 116 | } 117 | const caveat = createNewCapabilitiesCaveat(t.serviceName, t.capabilities) 118 | expect(caveat.condition).to.equal( 119 | t.serviceName + SERVICE_CAPABILITIES_SUFFIX 120 | ) 121 | if (Array.isArray(t.capabilities)) { 122 | expect(caveat.value).to.equal(t.capabilities.join(',')) 123 | } else { 124 | expect(caveat.value).to.equal(t.capabilities) 125 | } 126 | } 127 | }) 128 | 129 | it('can decode capabilities caveat value', () => { 130 | // @ts-expect-error 131 | expect(() => decodeCapabilitiesValue(3)).to.throw(InvalidCapabilitiesError) 132 | const value = 'add, subtract,multiply' 133 | const expected = ['add', 'subtract', 'multiply'] 134 | expect(decodeCapabilitiesValue(value)).to.have.all.members(expected) 135 | }) 136 | }) 137 | -------------------------------------------------------------------------------- /tests/utilities.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from 'crypto' 2 | 3 | import { invoice } from './data' 4 | import { Identifier, MacaroonClass } from '../src' 5 | import { getIdFromRequest } from '../src/helpers' 6 | import * as Macaroon from 'macaroon' 7 | 8 | export function getTestBuilder(secret: string): MacaroonClass { 9 | const paymentHash = getIdFromRequest(invoice.payreq) 10 | 11 | const identifier = new Identifier({ 12 | paymentHash: Buffer.from(paymentHash, 'hex'), 13 | tokenId: randomBytes(32), 14 | }) 15 | const macaroon = Macaroon.newMacaroon({ 16 | version: 1, 17 | rootKey: secret, 18 | identifier: identifier.toString(), 19 | location: 'location', 20 | }) 21 | return macaroon 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "lib": ["DOM","es2017"], 6 | "strict": true, 7 | "baseUrl": ".", 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "resolveJsonModule": true, 11 | "noUnusedParameters": false, 12 | "noUnusedLocals": false, 13 | "allowJs": true 14 | }, 15 | "include": ["src/**/*.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "declaration": true, 6 | "rootDir": "src", 7 | "typeRoots": ["node_modules/@types", "src/types"] 8 | }, 9 | "include": ["src/**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": ["tests/**/*.ts", "src/types"] 4 | } 5 | --------------------------------------------------------------------------------