diff --git a/common/jsonrpc_errors.h b/common/jsonrpc_errors.h index 5c9e974d92b7..ee4f4d6d481e 100644 --- a/common/jsonrpc_errors.h +++ b/common/jsonrpc_errors.h @@ -111,6 +111,11 @@ enum jsonrpc_errcode { /* Errors from delforward command */ DELFORWARD_NOT_FOUND = 1401, + /* Errors from runes */ + RUNE_NOT_AUTHORIZED = 1501, + RUNE_NOT_PERMITTED = 1502, + RUNE_BLACKLISTED = 1503, + /* Errors from wait* commands */ WAIT_TIMEOUT = 2000, }; diff --git a/doc/Makefile b/doc/Makefile index ceea02655b40..f74c3fe568b7 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -18,8 +18,10 @@ MANPAGES := doc/lightning-cli.1 \ doc/lightning-bkpr-listaccountevents.7 \ doc/lightning-bkpr-listbalances.7 \ doc/lightning-bkpr-listincome.7 \ + doc/lightning-blacklistrune.7 \ doc/lightning-check.7 \ doc/lightning-checkmessage.7 \ + doc/lightning-checkrune.7 \ doc/lightning-close.7 \ doc/lightning-connect.7 \ doc/lightning-commando.7 \ @@ -28,6 +30,7 @@ MANPAGES := doc/lightning-cli.1 \ doc/lightning-commando-rune.7 \ doc/lightning-createonion.7 \ doc/lightning-createinvoice.7 \ + doc/lightning-createrune.7 \ doc/lightning-datastore.7 \ doc/lightning-decodepay.7 \ doc/lightning-decode.7 \ @@ -65,6 +68,7 @@ MANPAGES := doc/lightning-cli.1 \ doc/lightning-listpays.7 \ doc/lightning-listpeers.7 \ doc/lightning-listpeerchannels.7 \ + doc/lightning-listrunes.7 \ doc/lightning-listsendpays.7 \ doc/lightning-makesecret.7 \ doc/lightning-multifundchannel.7 \ diff --git a/doc/index.rst b/doc/index.rst index 2cbf78563f27..66a17fd42d4d 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -40,8 +40,10 @@ Core Lightning Documentation lightning-bkpr-listaccountevents lightning-bkpr-listbalances lightning-bkpr-listincome + lightning-blacklistrune lightning-check lightning-checkmessage + lightning-checkrune lightning-cli lightning-close lightning-commando-blacklist @@ -51,6 +53,7 @@ Core Lightning Documentation lightning-connect lightning-createinvoice lightning-createonion + lightning-createrune lightning-datastore lightning-decode lightning-decodepay @@ -93,6 +96,7 @@ Core Lightning Documentation lightning-listpays lightning-listpeerchannels lightning-listpeers + lightning-listrunes lightning-listsendpays lightning-listsqlschemas lightning-listtransactions diff --git a/doc/lightning-blacklistrune.7.md b/doc/lightning-blacklistrune.7.md new file mode 100644 index 000000000000..081ed90152b3 --- /dev/null +++ b/doc/lightning-blacklistrune.7.md @@ -0,0 +1,42 @@ +lightning-blacklistrune -- Command to prevent a rune from working +============================================================== + +SYNOPSIS +-------- + +**blacklistrune** [*start* [*end*]] + +DESCRIPTION +----------- + +The **blacklistrune** RPC command allows you to effectively revoke the rune you have created (and any runes derived from that rune with additional restictions). Attempting to use these runes will be resulted in a `Blacklisted rune` error message. + +All runes created by lightning have a unique sequential id within them and can be blacklisted in ranges for efficiency. The command always returns the blacklisted ranges on success. If no parameters are specified, no changes have been made. If start specified without end, that single rune is blacklisted. If end is also specified, every rune from start till end inclusive is blacklisted. + +RETURN VALUE +------------ + +[comment]: # (GENERATE-FROM-SCHEMA-START) +On success, an object containing **blacklist** is returned. It is an array of objects, where each object contains: + +- **start** (u64): Unique id of first rune in this blacklist range +- **end** (u64): Unique id of last rune in this blacklist range + +[comment]: # (GENERATE-FROM-SCHEMA-END) + +AUTHOR +------ + +Shahana Farooqui <> is mainly responsible. + +SEE ALSO +-------- + +lightning-commando-blacklist(7), lightning-listrunes(7) + +RESOURCES +--------- + +Main web site: + +[comment]: # ( SHA256STAMP:a165eb0086559c67fd2992bd736450fc5cb60d5607b94b095782e5c43b945e66) diff --git a/doc/lightning-checkrune.7.md b/doc/lightning-checkrune.7.md new file mode 100644 index 000000000000..855fd00528b5 --- /dev/null +++ b/doc/lightning-checkrune.7.md @@ -0,0 +1,41 @@ +lightning-checkrune -- Command to Validate Rune +================================================ + +SYNOPSIS +-------- + +**checkrune** [*nodeid*], [*rune*], [*method*] [*params*] + +DESCRIPTION +----------- + +The **checkrune** RPC command checks the validity/authorization rights of specified rune for the given nodeid, method, and params. + +It will return {valid: true} if the rune is authorized otherwise returns error message. + +RETURN VALUE +------------ + +[comment]: # (GENERATE-FROM-SCHEMA-START) +On success, an object is returned, containing: + +- **valid** (boolean): true if the rune is valid + +[comment]: # (GENERATE-FROM-SCHEMA-END) + +AUTHOR +------ + +Shahana Farooqui <> is mainly responsible +for consolidating logic from commando. + +SEE ALSO +-------- + +lightning-createrune(7), lightning-blacklistrune(7) + +RESOURCES +--------- + +Main web site: +[comment]: # ( SHA256STAMP:977acf366f8fde1411f2c78d072b34b38b456e95381a6bce8fe6855a2d91434a) diff --git a/doc/lightning-createrune.7.md b/doc/lightning-createrune.7.md new file mode 100644 index 000000000000..e12fbe28951c --- /dev/null +++ b/doc/lightning-createrune.7.md @@ -0,0 +1,221 @@ +lightning-createrune -- Command to Create/Update Rune for Authorizing Remote Peer Access +========================================================================================= + +SYNOPSIS +-------- + +**createrune** [*rune*] [*restrictions*] + +DESCRIPTION +----------- + +The **createrune** RPC command creates a base64 string called a +*rune* which can be used to access commands on this node. Each *rune* +contains a unique id (a number starting at 0), and can have +restrictions inside it. Nobody can remove restrictions from a rune: if +you try, the rune will be rejected. There is no limit on how many +runes you can issue; the node simply decodes and checks them as they are +received. + +If *rune* is supplied, the restrictions are simple appended to that +*rune* (it doesn't need to be a rune belonging to this node). If no +*rune* is supplied, a new one is constructed, with a new unique id. + +*restrictions* can be the string "readonly" (creates a rune which +allows most *get* and *list* commands, and the *summary* command), or +an array of restrictions. + +Each restriction is an array of one or more alternatives, such as "method +is listpeers", or "method is listpeers OR time is before 2023". Alternatives use a simple language to examine the command which is +being run: + +* time: the current UNIX time, e.g. "time<1656759180". +* id: the node\_id of the peer, e.g. "id=024b9a1fa8e006f1e3937f65f66c408e6da8e1ca728ea43222a7381df1cc449605". +* method: the command being run, e.g. "method=withdraw". +* rate: the rate limit, per minute, e.g. "rate=60". +* pnum: the number of parameters. e.g. "pnum<2". +* pnameX: the parameter named X (with any punctuation like `_` removed). e.g. "pnamedestination=1RustyRX2oai4EYYDpQGWvEL62BBGqN9T". +* parrN: the N'th parameter. e.g. "parr0=1RustyRX2oai4EYYDpQGWvEL62BBGqN9T". + +RESTRICTION FORMAT +------------------ + +Restrictions are one or more alternatives. Each +alternative is *name* *operator* *value*. The valid names are shown +above. Note that if a value contains `\\`, it must be preceeded by another `\\` +to form valid JSON: + +* `=`: passes if equal ie. identical. e.g. `method=withdraw` +* `/`: not equals, e.g. `method/withdraw` +* `^`: starts with, e.g. `id^024b9a1fa8e006f1e3937f` +* `$`: ends with, e.g. `id$381df1cc449605`. +* `~`: contains, e.g. `id~006f1e3937f65f66c40`. +* `<`: is a decimal integer, and is less than. e.g. `time<1656759180` +* `>`: is a decimal integer, and is greater than. e.g. `time>1656759180` +* `{`: preceeds in alphabetical order (or matches but is shorter), e.g. `id{02ff`. +* `}`: follows in alphabetical order (or matches but is longer), e.g. `id}02ff`. +* `#`: a comment, ignored, e.g. `dumb example#`. +* `!`: only passes if the *name* does *not* exist. e.g. `pnamedestination!`. + Every other operator except `#` fails if *name* does not exist! + +EXAMPLES +-------- + +This creates a fresh rune which can do anything: + + $ lightning-cli createrune + { + "rune": "KUhZzNlECC7pYsz3QVbF1TqjIUYi3oyESTI7n60hLMs9MA==", + "unique_id": "0" + } + +We can add restrictions to that rune, like so: + + $ lightning-cli createrune rune=KUhZzNlECC7pYsz3QVbF1TqjIUYi3oyESTI7n60hLMs9MA== restrictions=readonly + { + "rune": "NbL7KkXcPQsVseJ9TdJNjJK2KsPjnt_q4cE_wvc873I9MCZtZXRob2RebGlzdHxtZXRob2ReZ2V0fG1ldGhvZD1zdW1tYXJ5Jm1ldGhvZC9saXN0ZGF0YXN0b3Jl", + "unique_id": "0" + } + +The "readonly" restriction is a short-cut for two restrictions: + +1. `["method^list", "method^get", "method=summary"]`: You may call list, get or summary. +2. `["method/listdatastore"]`: But not listdatastore: that contains sensitive stuff! + +We can do the same manually, like so: + + $ lightning-cli createrune rune=KUhZzNlECC7pYsz3QVbF1TqjIUYi3oyESTI7n60hLMs9MA== restrictions='[["method^list", "method^get", "method=summary"],["method/listdatastore"]]' + { + "rune": "NbL7KkXcPQsVseJ9TdJNjJK2KsPjnt_q4cE_wvc873I9MCZtZXRob2RebGlzdHxtZXRob2ReZ2V0fG1ldGhvZD1zdW1tYXJ5Jm1ldGhvZC9saXN0ZGF0YXN0b3Jl", + "unique_id": "0" + } + +Let's create a rune which lets a specific peer +(024b9a1fa8e006f1e3937f65f66c408e6da8e1ca728ea43222a7381df1cc449605) +run "listpeers" on themselves: + + $ lightning-cli createrune restrictions='[["id=024b9a1fa8e006f1e3937f65f66c408e6da8e1ca728ea43222a7381df1cc449605"],["method=listpeers"],["pnum=1"],["pnameid=024b9a1fa8e006f1e3937f65f66c408e6da8e1ca728ea43222a7381df1cc449605","parr0=024b9a1fa8e006f1e3937f65f66c408e6da8e1ca728ea43222a7381df1cc449605"]]' + { + "rune": "FE8GHiGVvxcFqCQcClVRRiNE_XEeLYQzyG2jmqto4jM9MiZpZD0wMjRiOWExZmE4ZTAwNmYxZTM5MzdmNjVmNjZjNDA4ZTZkYThlMWNhNzI4ZWE0MzIyMmE3MzgxZGYxY2M0NDk2MDUmbWV0aG9kPWxpc3RwZWVycyZwbnVtPTEmcG5hbWVpZD0wMjRiOWExZmE4ZTAwNmYxZTM5MzdmNjVmNjZjNDA4ZTZkYThlMWNhNzI4ZWE0MzIyMmE3MzgxZGYxY2M0NDk2MDV8cGFycjA9MDI0YjlhMWZhOGUwMDZmMWUzOTM3ZjY1ZjY2YzQwOGU2ZGE4ZTFjYTcyOGVhNDMyMjJhNzM4MWRmMWNjNDQ5NjA1", + "unique_id": "2" + } + +This allows `listpeers` with 1 argument (`pnum=1`), which is either by name (`pnameid`), or position (`parr0`). We could shorten this in several ways: either allowing only positional or named parameters, or by testing the start of the parameters only. Here's an example which only checks the first 9 bytes of the `listpeers` parameter: + + $ lightning-cli createrune restrictions='[["id=024b9a1fa8e006f1e3937f65f66c408e6da8e1ca728ea43222a7381df1cc449605"],["method=listpeers"],["pnum=1"],["pnameid^024b9a1fa8e006f1e393", "parr0^024b9a1fa8e006f1e393"]' + { + "rune": "fTQnfL05coEbiBO8SS0cvQwCcPLxE9c02pZCC6HRVEY9MyZpZD0wMjRiOWExZmE4ZTAwNmYxZTM5MzdmNjVmNjZjNDA4ZTZkYThlMWNhNzI4ZWE0MzIyMmE3MzgxZGYxY2M0NDk2MDUmbWV0aG9kPWxpc3RwZWVycyZwbnVtPTEmcG5hbWVpZF4wMjRiOWExZmE4ZTAwNmYxZTM5M3xwYXJyMF4wMjRiOWExZmE4ZTAwNmYxZTM5Mw==", + "unique_id": "3" + } + +Before we give this to our peer, let's add two more restrictions: that +it only be usable for 24 hours from now (`time<`), and that it can only +be used twice a minute (`rate=2`). `date +%s` can give us the current +time in seconds: + + $ lightning-cli createrune rune=fTQnfL05coEbiBO8SS0cvQwCcPLxE9c02pZCC6HRVEY9MyZpZD0wMjRiOWExZmE4ZTAwNmYxZTM5MzdmNjVmNjZjNDA4ZTZkYThlMWNhNzI4ZWE0MzIyMmE3MzgxZGYxY2M0NDk2MDUmbWV0aG9kPWxpc3RwZWVycyZwbnVtPTEmcG5hbWVpZF4wMjRiOWExZmE4ZTAwNmYxZTM5M3xwYXJyMF4wMjRiOWExZmE4ZTAwNmYxZTM5Mw== restrictions='[["time<'$(($(date +%s) + 24*60*60))'","rate=2"]]' + { + "rune": "tU-RLjMiDpY2U0o3W1oFowar36RFGpWloPbW9-RuZdo9MyZpZD0wMjRiOWExZmE4ZTAwNmYxZTM5MzdmNjVmNjZjNDA4ZTZkYThlMWNhNzI4ZWE0MzIyMmE3MzgxZGYxY2M0NDk2MDUmbWV0aG9kPWxpc3RwZWVycyZwbnVtPTEmcG5hbWVpZF4wMjRiOWExZmE4ZTAwNmYxZTM5M3xwYXJyMF4wMjRiOWExZmE4ZTAwNmYxZTM5MyZ0aW1lPDE2NTY5MjA1MzgmcmF0ZT0y", + "unique_id": "3" + } + +You can also use lightning-decode(7) to examine runes you have been given: + + $ .lightning-cli decode tU-RLjMiDpY2U0o3W1oFowar36RFGpWloPbW9-RuZdo9MyZpZD0wMjRiOWExZmE4ZTAwNmYxZTM5MzdmNjVmNjZjNDA4ZTZkYThlMWNhNzI4ZWE0MzIyMmE3MzgxZGYxY2M0NDk2MDUmbWV0aG9kPWxpc3RwZWVycyZwbnVtPTEmcG5hbWVpZF4wMjRiOWExZmE4ZTAwNmYxZTM5M3xwYXJyMF4wMjRiOWExZmE4ZTAwNmYxZTM5MyZ0aW1lPDE2NTY5MjA1MzgmcmF0ZT0y + { + "type": "rune", + "unique_id": "3", + "string": "b54f912e33220e9636534a375b5a05a306abdfa4451a95a5a0f6d6f7e46e65da:=3&id=024b9a1fa8e006f1e3937f65f66c408e6da8e1ca728ea43222a7381df1cc449605&method=listpeers&pnum=1&pnameid^024b9a1fa8e006f1e393|parr0^024b9a1fa8e006f1e393&time<1656920538&rate=2", + "restrictions": [ + { + "alternatives": [ + "id=024b9a1fa8e006f1e3937f65f66c408e6da8e1ca728ea43222a7381df1cc449605" + ], + "summary": "id (of commanding peer) equal to '024b9a1fa8e006f1e3937f65f66c408e6da8e1ca728ea43222a7381df1cc449605'" + }, + { + "alternatives": [ + "method=listpeers" + ], + "summary": "method (of command) equal to 'listpeers'" + }, + { + "alternatives": [ + "pnum=1" + ], + "summary": "pnum (number of command parameters) equal to 1" + }, + { + "alternatives": [ + "pnameid^024b9a1fa8e006f1e393", + "parr0^024b9a1fa8e006f1e393" + ], + "summary": "pnameid (object parameter 'id') starts with '024b9a1fa8e006f1e393' OR parr0 (array parameter #0) starts with '024b9a1fa8e006f1e393'" + }, + { + "alternatives": [ + "time<1656920538" + ], + "summary": "time (in seconds since 1970) less than 1656920538 (approximately 19 hours 18 minutes from now)" + }, + { + "alternatives": [ + "rate=2" + ], + "summary": "rate (max per minute) equal to 2" + } + ], + "valid": true + } + + +SHARING RUNES +------------- + +Because anyone can add a restriction to a rune, you can always turn a +normal rune into a read-only rune, or restrict access for 30 minutes +from the time you give it to someone. Adding restrictions before +sharing runes is best practice. + +If a rune has a ratelimit, any derived rune will have the same id, and +thus will compete for that ratelimit. You might want to consider +adding a tighter ratelimit to a rune before sharing it, so you will +keep the remainder. For example, if you rune has a limit of 60 times +per minute, adding a limit of 5 times per minute and handing that rune +out means you can still use your original rune 55 times per minute. + +RETURN VALUE +------------ + +[comment]: # (GENERATE-FROM-SCHEMA-START) +On success, an object is returned, containing: + +- **rune** (string): the resulting rune +- **unique\_id** (string): the id of this rune: this is set at creation and cannot be changed (even as restrictions are added) + +The following warnings may also be returned: + +- **warning\_unrestricted\_rune**: A warning shown when runes are created with powers that could drain your node + +[comment]: # (GENERATE-FROM-SCHEMA-END) + +AUTHOR +------ + +Rusty Russell <> wrote the original Python +commando.py plugin, the in-tree commando plugin, and this manual page. + +Shahana Farooqui <> is mainly responsible +for migrating commando-rune to createrune. + +SEE ALSO +-------- + +lightning-commando-rune(7), lightning-checkrune(7) + +RESOURCES +--------- + +Main web site: + +[comment]: # ( SHA256STAMP:7064d2dcc37af3fe83739a11da57400b5c1faef51095b8dacfba6a4312fc9d25) diff --git a/doc/lightning-listrunes.7.md b/doc/lightning-listrunes.7.md new file mode 100644 index 000000000000..4de91439b85b --- /dev/null +++ b/doc/lightning-listrunes.7.md @@ -0,0 +1,51 @@ +lightning-listrunes -- Command to list previously generated runes +================================================================== + +SYNOPSIS +-------- + +**listrunes** [*rune*] + +DESCRIPTION +----------- + +The **listrunes** RPC command either lists runes that we stored as we generate them (see lightning-createrune(7)) or decodes the rune given on the command line. + +RETURN VALUE +------------ + +[comment]: # (GENERATE-FROM-SCHEMA-START) +On success, an object containing **runes** is returned. It is an array of objects, where each object contains: + +- **rune** (string): Base64 encoded rune +- **unique\_id** (string): Unique id assigned when the rune was generated; this is always a u64 for commando runes +- **restrictions** (array of objects): The restrictions on what commands this rune can authorize: + - **alternatives** (array of objects): + - **fieldname** (string): The field this restriction applies to; see commando-rune(7) + - **value** (string): The value accepted for this field + - **condition** (string): The way to compare fieldname and value + - **english** (string): English readable description of this alternative + - **english** (string): English readable summary of alternatives above +- **restrictions\_as\_english** (string): English readable description of the restrictions array above +- **stored** (boolean, optional): This is false if the rune does not appear in our datastore (only possible when `rune` is specified) (always *false*) +- **blacklisted** (boolean, optional): The rune has been blacklisted; see commando-blacklist(7) (always *true*) +- **our\_rune** (boolean, optional): This is not a rune for this node (only possible when `rune` is specified) (always *false*) + +[comment]: # (GENERATE-FROM-SCHEMA-END) + +AUTHOR +------ + +Shahana Farooqui <> is mainly responsible. + +SEE ALSO +-------- + +lightning-commando-listrunes(7), lightning-blacklistrune(7) + +RESOURCES +--------- + +Main web site: + +[comment]: # ( SHA256STAMP:cd0e75bbeef3d5824448f67485de4679b0c163e97f405673b2ba9495f970d498) diff --git a/doc/schemas/blacklistrune.request.json b/doc/schemas/blacklistrune.request.json new file mode 100644 index 000000000000..e2d16b2ab3e5 --- /dev/null +++ b/doc/schemas/blacklistrune.request.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [], + "added": "v23.08", + "properties": { + "start": { + "type": "u64", + "description": "first rune unique id to blacklist" + }, + "end": { + "type": "u64", + "description": "final rune unique id to blacklist (defaults to start)" + } + } +} diff --git a/doc/schemas/blacklistrune.schema.json b/doc/schemas/blacklistrune.schema.json new file mode 100644 index 000000000000..86fb093862b4 --- /dev/null +++ b/doc/schemas/blacklistrune.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "blacklist" + ], + "properties": { + "blacklist": { + "type": "array", + "description": "the resulting blacklist ranges after the command", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "start", + "end" + ], + "properties": { + "start": { + "type": "u64", + "description": "Unique id of first rune in this blacklist range" + }, + "end": { + "type": "u64", + "description": "Unique id of last rune in this blacklist range" + } + } + } + } + } +} diff --git a/doc/schemas/checkrune.request.json b/doc/schemas/checkrune.request.json new file mode 100644 index 000000000000..0b55e870c2b8 --- /dev/null +++ b/doc/schemas/checkrune.request.json @@ -0,0 +1,42 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "nodeid", + "rune", + "method" + ], + "added": "v23.08", + "properties": { + "nodeid": { + "type": "string", + "description": "node id of your node" + }, + "method": { + "type": "string", + "description": "method for which rune needs to be validated" + }, + "rune": { + "type": "string", + "description": "rune to check for authorization" + }, + "params": { + "oneOf": [ + { + "type": "array", + "description": "array of positional parameters" + }, + { + "type": "object", + "description": "parameters for method" + } + ] + }, + "filter": { + "type": "object", + "additionalProperties": true, + "description": "filter to apply to any successful result" + } + } +} diff --git a/doc/schemas/checkrune.schema.json b/doc/schemas/checkrune.schema.json new file mode 100644 index 000000000000..3262c3bd3e0a --- /dev/null +++ b/doc/schemas/checkrune.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "valid" + ], + "properties": { + "valid": { + "type": "boolean", + "description": "true if the rune is valid" + } + } +} diff --git a/doc/schemas/commando-blacklist.request.json b/doc/schemas/commando-blacklist.request.json index 1bb54235560c..72e3d9c2ec3a 100644 --- a/doc/schemas/commando-blacklist.request.json +++ b/doc/schemas/commando-blacklist.request.json @@ -4,6 +4,7 @@ "additionalProperties": false, "required": [], "added": "v23.05", + "deprecated": "v23.08", "properties": { "start": { "type": "u64", diff --git a/doc/schemas/commando-listrunes.request.json b/doc/schemas/commando-listrunes.request.json index 9cb47ee44ac7..eb65629ea725 100644 --- a/doc/schemas/commando-listrunes.request.json +++ b/doc/schemas/commando-listrunes.request.json @@ -4,6 +4,7 @@ "additionalProperties": false, "required": [], "added": "v23.05", + "deprecated": "v23.08", "properties": { "rune": { "type": "string", diff --git a/doc/schemas/commando-rune.request.json b/doc/schemas/commando-rune.request.json index ef5678a9b2bc..8d6e481ca9fb 100644 --- a/doc/schemas/commando-rune.request.json +++ b/doc/schemas/commando-rune.request.json @@ -3,6 +3,7 @@ "type": "object", "additionalProperties": false, "required": [], + "deprecated": "v23.08", "properties": { "rune": { "type": "string", diff --git a/doc/schemas/createrune.request.json b/doc/schemas/createrune.request.json new file mode 100644 index 000000000000..00983287f449 --- /dev/null +++ b/doc/schemas/createrune.request.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [], + "added": "v23.08", + "properties": { + "rune": { + "type": "string", + "description": "optional rune to add to" + }, + "restrictions": { + "oneOf": [ + { + "type": "array", + "description": "array of restrictions to add to rune", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "type": "string", + "enum": [ + "readonly" + ], + "description": "readonly string to indicate standard readonly restrictions." + } + ] + } + } +} diff --git a/doc/schemas/createrune.schema.json b/doc/schemas/createrune.schema.json new file mode 100644 index 000000000000..2bb8483aa21e --- /dev/null +++ b/doc/schemas/createrune.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "rune", + "unique_id" + ], + "properties": { + "rune": { + "type": "string", + "description": "the resulting rune" + }, + "unique_id": { + "type": "string", + "description": "the id of this rune: this is set at creation and cannot be changed (even as restrictions are added)" + }, + "warning_unrestricted_rune": { + "type": "string", + "description": "A warning shown when runes are created with powers that could drain your node" + } + } +} diff --git a/doc/schemas/listrunes.request.json b/doc/schemas/listrunes.request.json new file mode 100644 index 000000000000..5bc5b8fc8322 --- /dev/null +++ b/doc/schemas/listrunes.request.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [], + "added": "v23.08", + "properties": { + "rune": { + "type": "string", + "description": "optional rune to list" + } + } +} diff --git a/doc/schemas/listrunes.schema.json b/doc/schemas/listrunes.schema.json new file mode 100644 index 000000000000..c485d65b5d73 --- /dev/null +++ b/doc/schemas/listrunes.schema.json @@ -0,0 +1,107 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "runes" + ], + "properties": { + "runes": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "rune", + "unique_id", + "restrictions", + "restrictions_as_english" + ], + "properties": { + "rune": { + "type": "string", + "description": "Base64 encoded rune" + }, + "unique_id": { + "type": "string", + "description": "Unique id assigned when the rune was generated; this is always a u64 for commando runes" + }, + "restrictions": { + "type": "array", + "description": "The restrictions on what commands this rune can authorize", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "alternatives", + "english" + ], + "properties": { + "alternatives": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "fieldname", + "value", + "condition", + "english" + ], + "properties": { + "fieldname": { + "type": "string", + "description": "The field this restriction applies to; see commando-rune(7)" + }, + "value": { + "type": "string", + "description": "The value accepted for this field" + }, + "condition": { + "type": "string", + "description": "The way to compare fieldname and value" + }, + "english": { + "type": "string", + "description": "English readable description of this alternative" + } + } + } + }, + "english": { + "type": "string", + "description": "English readable summary of alternatives above" + } + } + } + }, + "restrictions_as_english": { + "type": "string", + "description": "English readable description of the restrictions array above" + }, + "stored": { + "type": "boolean", + "enum": [ + false + ], + "description": "This is false if the rune does not appear in our datastore (only possible when `rune` is specified)" + }, + "blacklisted": { + "type": "boolean", + "enum": [ + true + ], + "description": "The rune has been blacklisted; see commando-blacklist(7)" + }, + "our_rune": { + "type": "boolean", + "enum": [ + false + ], + "description": "This is not a rune for this node (only possible when `rune` is specified)" + } + } + } + } + } +} diff --git a/lightningd/Makefile b/lightningd/Makefile index 15bed238e6f2..0085da531404 100644 --- a/lightningd/Makefile +++ b/lightningd/Makefile @@ -37,6 +37,7 @@ LIGHTNINGD_SRC := \ lightningd/plugin_control.c \ lightningd/plugin_hook.c \ lightningd/routehint.c \ + lightningd/runes.c \ lightningd/subd.c \ lightningd/watch.c diff --git a/lightningd/lightningd.c b/lightningd/lightningd.c index f85cc94fe49d..c420337a51fe 100644 --- a/lightningd/lightningd.c +++ b/lightningd/lightningd.c @@ -71,6 +71,7 @@ #include #include #include +#include #include #include #include @@ -1118,6 +1119,11 @@ int main(int argc, char *argv[]) else if (max_blockheight != UINT32_MAX) max_blockheight -= ld->config.rescan; + /*~ We have bearer tokens called `runes` you can use to control access. They have + * a fascinating history which I shall not go into now, but they're derived from + * Macaroons which was a over-engineered Googlism. */ + ld->runes = runes_init(ld); + /*~ That's all of the wallet db operations for now. */ db_commit_transaction(ld->wallet->db); diff --git a/lightningd/lightningd.h b/lightningd/lightningd.h index 6f8ffd9929cb..ac70e252ac36 100644 --- a/lightningd/lightningd.h +++ b/lightningd/lightningd.h @@ -371,6 +371,9 @@ struct lightningd { /* For anchors: how much do we keep for spending close txs? */ struct amount_sat emergency_sat; + + /* runes! */ + struct runes *runes; }; /* Turning this on allows a tal allocation to return NULL, rather than aborting. diff --git a/lightningd/runes.c b/lightningd/runes.c new file mode 100644 index 000000000000..dc40acf764e3 --- /dev/null +++ b/lightningd/runes.c @@ -0,0 +1,770 @@ +#include "config.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct usage { + /* If you really issue more than 2^32 runes, they'll share ratelimit buckets */ + u32 id; + u32 counter; +}; + +static u64 usage_id(const struct usage *u) +{ + return u->id; +} + +static size_t id_hash(u64 id) +{ + return siphash24(siphash_seed(), &id, sizeof(id)); +} + +static bool usage_eq_id(const struct usage *u, u64 id) +{ + return u->id == id; +} +HTABLE_DEFINE_TYPE(struct usage, usage_id, id_hash, usage_eq_id, usage_table); + +struct cond_info { + const struct runes *runes; + const struct node_id *peer; + const char *buf; + const char *method; + const jsmntok_t *params; + STRMAP(const jsmntok_t *) cached_params; + struct usage *usage; +}; + +/* This is lightningd->runes */ +struct runes { + struct lightningd *ld; + struct rune *master; + u64 next_unique_id; + struct rune_blacklist *blacklist; + struct usage_table *usage_table; +}; + +#if DEVELOPER +static void memleak_help_usage_table(struct htable *memtable, + struct usage_table *usage_table) +{ + memleak_scan_htable(memtable, &usage_table->raw); +} +#endif /* DEVELOPER */ + +/* Every minute we forget entries. */ +static void flush_usage_table(struct runes *runes) +{ + tal_free(runes->usage_table); + runes->usage_table = tal(runes, struct usage_table); + usage_table_init(runes->usage_table); + memleak_add_helper(runes->usage_table, memleak_help_usage_table); + + notleak(new_reltimer(runes->ld->timers, runes, time_from_sec(60), flush_usage_table, runes)); +} + +static const char *rate_limit_check(const tal_t *ctx, + const struct runes *runes, + const struct rune *rune, + const struct rune_altern *alt, + struct cond_info *cinfo) +{ + unsigned long r; + char *endp; + if (alt->condition != '=') + return "rate operator must be ="; + + r = strtoul(alt->value, &endp, 10); + if (endp == alt->value || *endp || r == 0 || r >= UINT32_MAX) + return "malformed rate"; + + /* We cache this: we only add usage counter if whole rune succeeds! */ + if (!cinfo->usage) { + cinfo->usage = usage_table_get(runes->usage_table, atol(rune->unique_id)); + if (!cinfo->usage) { + cinfo->usage = tal(runes->usage_table, struct usage); + cinfo->usage->id = atol(rune->unique_id); + cinfo->usage->counter = 0; + usage_table_add(runes->usage_table, cinfo->usage); + } + } + + /* >= becuase if we allow this, counter will increment */ + if (cinfo->usage->counter >= r) + return tal_fmt(ctx, "Rate of %lu per minute exceeded", r); + + return NULL; +} + +struct runes *runes_init(struct lightningd *ld) +{ + const u8 *msg; + struct runes *runes = tal(ld, struct runes); + const u8 *data; + struct secret secret; + + runes->ld = ld; + runes->next_unique_id = db_get_intvar(ld->wallet->db, "runes_uniqueid", 0); + runes->blacklist = wallet_get_runes_blacklist(runes, ld->wallet); + + /* Runes came out of commando, hence the derivation key is 'commando' */ + data = tal_dup_arr(tmpctx, u8, (u8 *)"commando", strlen("commando"), 0); + msg = hsm_sync_req(tmpctx, ld, towire_hsmd_derive_secret(tmpctx, data)); + if (!fromwire_hsmd_derive_secret_reply(msg, &secret)) + fatal("Bad reply from HSM: %s", tal_hex(tmpctx, msg)); + + runes->master = rune_new(runes, secret.data, ARRAY_SIZE(secret.data), NULL); + + /* Initialize usage table and start flush timer. */ + runes->usage_table = NULL; + flush_usage_table(runes); + + return runes; +} + +struct rune_and_string { + const char *runestr; + struct rune *rune; +}; + +static struct command_result *param_rune(struct command *cmd, const char *name, + const char * buffer, const jsmntok_t *tok, + struct rune_and_string **rune_and_string) +{ + *rune_and_string = tal(cmd, struct rune_and_string); + (*rune_and_string)->runestr = json_strdup(*rune_and_string, buffer, tok); + (*rune_and_string)->rune = rune_from_base64(cmd, (*rune_and_string)->runestr); + if (!(*rune_and_string)->rune) + return command_fail_badparam(cmd, name, buffer, tok, + "should be base64 string"); + + return NULL; +} + +static struct command_result *param_params(struct command *cmd, const char *name, + const char * buffer, const jsmntok_t *tok, + const jsmntok_t **params) +{ + if (tok->type != JSMN_OBJECT && tok->type != JSMN_ARRAY) { + return command_fail_badparam(cmd, name, buffer, tok, "must be object or array"); + } + *params = tok; + return NULL; +} + +/* The unique id is embedded with a special restriction with an empty field name */ +static bool is_unique_id(struct rune_restr **restrs, unsigned int index) +{ + /* must be the first restriction */ + if (index != 0) + return false; + + /* Must be the only alternative */ + if (tal_count(restrs[index]->alterns) != 1) + return false; + + /* Must have an empty field name */ + return streq(restrs[index]->alterns[0]->fieldname, ""); +} + +static char *rune_altern_to_english(const tal_t *ctx, const struct rune_altern *alt) +{ + const char *cond_str; + switch (alt->condition) { + case RUNE_COND_IF_MISSING: + return tal_strcat(ctx, alt->fieldname, " is missing"); + case RUNE_COND_EQUAL: + cond_str = "equal to"; + break; + case RUNE_COND_NOT_EQUAL: + cond_str = "unequal to"; + break; + case RUNE_COND_BEGINS: + cond_str = "starts with"; + break; + case RUNE_COND_ENDS: + cond_str = "ends with"; + break; + case RUNE_COND_CONTAINS: + cond_str = "contains"; + break; + case RUNE_COND_INT_LESS: + cond_str = "<"; + break; + case RUNE_COND_INT_GREATER: + cond_str = ">"; + break; + case RUNE_COND_LEXO_BEFORE: + cond_str = "sorts before"; + break; + case RUNE_COND_LEXO_AFTER: + cond_str = "sorts after"; + break; + case RUNE_COND_COMMENT: + return tal_fmt(ctx, "comment: %s %s", alt->fieldname, alt->value); + } + return tal_fmt(ctx, "%s %s %s", alt->fieldname, cond_str, alt->value); +} + +static char *json_add_alternative(const tal_t *ctx, + struct json_stream *js, + const char *fieldname, + struct rune_altern *alternative) +{ + char *altern_english; + altern_english = rune_altern_to_english(ctx, alternative); + json_object_start(js, fieldname); + json_add_string(js, "fieldname", alternative->fieldname); + json_add_string(js, "value", alternative->value); + json_add_stringn(js, "condition", (char *)&alternative->condition, 1); + json_add_string(js, "english", altern_english); + json_object_end(js); + return altern_english; +} + +static bool is_rune_blacklisted(const struct runes *runes, const struct rune *rune) +{ + u64 uid; + + /* Every rune *we produce* has a unique_id which is a number, but + * it's legal to have a rune without one. */ + if (rune->unique_id == NULL) { + return false; + } + uid = atol(rune->unique_id); + for (size_t i = 0; i < tal_count(runes->blacklist); i++) { + if (runes->blacklist[i].start <= uid && runes->blacklist[i].end >= uid) { + return true; + } + } + return false; +} + +static void join_strings(char **base, const char *connector, char *append) +{ + if (streq(*base, "")) { + *base = append; + } else { + tal_append_fmt(base, " %s %s", connector, append); + } +} + +static struct command_result *json_add_rune(struct lightningd *ld, + struct json_stream *js, + const char *fieldname, + const char *runestr, + const struct rune *rune, + bool stored) +{ + char *rune_english; + rune_english = ""; + json_object_start(js, fieldname); + json_add_string(js, "rune", runestr); + if (!stored) { + json_add_bool(js, "stored", false); + } + if (is_rune_blacklisted(ld->runes, rune)) { + json_add_bool(js, "blacklisted", true); + } + if (rune_is_derived(ld->runes->master, rune)) { + json_add_bool(js, "our_rune", false); + } + json_add_string(js, "unique_id", rune->unique_id); + json_array_start(js, "restrictions"); + for (size_t i = 0; i < tal_count(rune->restrs); i++) { + char *restr_english; + restr_english = ""; + /* Already printed out the unique id */ + if (is_unique_id(rune->restrs, i)) { + continue; + } + json_object_start(js, NULL); + json_array_start(js, "alternatives"); + for (size_t j = 0; j < tal_count(rune->restrs[i]->alterns); j++) { + join_strings(&restr_english, "OR", + json_add_alternative(tmpctx, js, NULL, rune->restrs[i]->alterns[j])); + } + json_array_end(js); + json_add_string(js, "english", restr_english); + json_object_end(js); + join_strings(&rune_english, "AND", restr_english); + } + json_array_end(js); + json_add_string(js, "restrictions_as_english", rune_english); + json_object_end(js); + return NULL; +} + +static struct command_result *json_listrunes(struct command *cmd, + const char *buffer, + const jsmntok_t *obj UNNEEDED, + const jsmntok_t *params) +{ + struct json_stream *response; + struct rune_and_string *ras; + + if (!param(cmd, buffer, params, + p_opt("rune", param_rune, &ras), NULL)) + return command_param_failed(); + + response = json_stream_success(cmd); + json_array_start(response, "runes"); + if (ras) { + long uid = atol(ras->rune->unique_id); + bool in_db = (wallet_get_rune(tmpctx, cmd->ld->wallet, uid) != NULL); + json_add_rune(cmd->ld, response, NULL, ras->runestr, ras->rune, in_db); + } else { + const char **strs = wallet_get_runes(cmd, cmd->ld->wallet); + for (size_t i = 0; i < tal_count(strs); i++) { + const struct rune *r = rune_from_base64(cmd, strs[i]); + json_add_rune(cmd->ld, response, NULL, strs[i], r, true); + } + } + json_array_end(response); + return command_success(cmd, response); +} + +static const struct json_command listrunes_command = { + "listrunes", + "utility", + json_listrunes, + "List a rune or list/decode an optional {rune}." +}; +AUTODATA(json_command, &listrunes_command); + +static struct rune_restr **readonly_restrictions(const tal_t *ctx) +{ + struct rune_restr **restrs = tal_arr(ctx, struct rune_restr *, 2); + + /* Any list*, get*, or summary: + * method^list|method^get|method=summary + */ + restrs[0] = rune_restr_new(restrs); + rune_restr_add_altern(restrs[0], + take(rune_altern_new(NULL, + "method", + RUNE_COND_BEGINS, + "list"))); + rune_restr_add_altern(restrs[0], + take(rune_altern_new(NULL, + "method", + RUNE_COND_BEGINS, + "get"))); + rune_restr_add_altern(restrs[0], + take(rune_altern_new(NULL, + "method", + RUNE_COND_EQUAL, + "summary"))); + /* But not listdatastore! + * method/listdatastore + */ + restrs[1] = rune_restr_new(restrs); + rune_restr_add_altern(restrs[1], + take(rune_altern_new(NULL, + "method", + RUNE_COND_NOT_EQUAL, + "listdatastore"))); + + return restrs; +} + +static struct rune_altern *rune_altern_from_json(const tal_t *ctx, + const char *buffer, + const jsmntok_t *tok) +{ + struct rune_altern *alt; + size_t condoff; + /* We still need to unescape here, for \\ -> \. JSON doesn't + * allow unnecessary \ */ + const char *unescape; + struct json_escape *e = json_escape_string_(tmpctx, + buffer + tok->start, + tok->end - tok->start); + unescape = json_escape_unescape(tmpctx, e); + if (!unescape) + return NULL; + + condoff = rune_altern_fieldname_len(unescape, strlen(unescape)); + if (!rune_condition_is_valid(unescape[condoff])) + return NULL; + + alt = tal(ctx, struct rune_altern); + alt->fieldname = tal_strndup(alt, unescape, condoff); + alt->condition = unescape[condoff]; + alt->value = tal_strdup(alt, unescape + condoff + 1); + return alt; +} + +static struct rune_restr *rune_restr_from_json(struct command *cmd, + const tal_t *ctx, + const char *buffer, + const jsmntok_t *tok) +{ + const jsmntok_t *t; + size_t i; + struct rune_restr *restr; + + /* \| is not valid JSON, so they use \\|: undo it! */ + if (cmd->ld->deprecated_apis && tok->type == JSMN_STRING) { + const char *unescape; + struct json_escape *e = json_escape_string_(tmpctx, + buffer + tok->start, + tok->end - tok->start); + unescape = json_escape_unescape(tmpctx, e); + if (!unescape) + return NULL; + return rune_restr_from_string(ctx, unescape, strlen(unescape)); + } + + restr = tal(ctx, struct rune_restr); + /* FIXME: after deprecation removed, allow singletons again! */ + if (tok->type != JSMN_ARRAY) + return NULL; + + restr->alterns = tal_arr(restr, struct rune_altern *, tok->size); + json_for_each_arr(i, t, tok) { + restr->alterns[i] = rune_altern_from_json(restr->alterns, + buffer, t); + if (!restr->alterns[i]) + return tal_free(restr); + } + return restr; +} + +static struct command_result *param_restrictions(struct command *cmd, + const char *name, + const char *buffer, + const jsmntok_t *tok, + struct rune_restr ***restrs) +{ + if (json_tok_streq(buffer, tok, "readonly")) + *restrs = readonly_restrictions(cmd); + else if (tok->type == JSMN_ARRAY) { + size_t i; + const jsmntok_t *t; + + *restrs = tal_arr(cmd, struct rune_restr *, tok->size); + json_for_each_arr(i, t, tok) { + (*restrs)[i] = rune_restr_from_json(cmd, *restrs, buffer, t); + if (!(*restrs)[i]) { + return command_fail_badparam(cmd, name, buffer, t, + "not a valid restriction (should be array)"); + } + } + } else { + *restrs = tal_arr(cmd, struct rune_restr *, 1); + (*restrs)[0] = rune_restr_from_json(cmd, *restrs, buffer, tok); + if (!(*restrs)[0]) + return command_fail_badparam(cmd, name, buffer, tok, + "not a valid restriction (should be array)"); + } + return NULL; +} + +static struct command_result *reply_with_rune(struct command *cmd, + const char *buf UNUSED, + const jsmntok_t *result UNUSED, + struct rune *rune) +{ + struct json_stream *js = json_stream_success(cmd); + + json_add_string(js, "rune", rune_to_base64(tmpctx, rune)); + json_add_string(js, "unique_id", rune->unique_id); + + if (tal_count(rune->restrs) <= 1) { + json_add_string(js, "warning_unrestricted_rune", "WARNING: This rune has no restrictions! Anyone who has access to this rune could drain funds from your node. Be careful when giving this to apps that you don't trust. Consider using the restrictions parameter to only allow access to specific rpc methods."); + } + return command_success(cmd, js); +} + +static struct command_result *json_createrune(struct command *cmd, + const char *buffer, + const jsmntok_t *obj UNNEEDED, + const jsmntok_t *params) +{ + struct rune_and_string *ras; + struct rune_restr **restrs; + + if (!param(cmd, buffer, params, + p_opt("rune", param_rune, &ras), + p_opt("restrictions", param_restrictions, &restrs), + NULL)) + return command_param_failed(); + + if (ras != NULL ) { + for (size_t i = 0; i < tal_count(restrs); i++) + rune_add_restr(ras->rune, restrs[i]); + return reply_with_rune(cmd, NULL, NULL, ras->rune); + } + + ras = tal(cmd, struct rune_and_string); + ras->rune = rune_derive_start(cmd, cmd->ld->runes->master, + tal_fmt(tmpctx, "%"PRIu64, cmd->ld->runes->next_unique_id ? cmd->ld->runes->next_unique_id : 0)); + ras->runestr = rune_to_base64(tmpctx, ras->rune); + + for (size_t i = 0; i < tal_count(restrs); i++) + rune_add_restr(ras->rune, restrs[i]); + + /* Insert into DB*/ + wallet_rune_insert(cmd->ld->wallet, ras->rune); + cmd->ld->runes->next_unique_id = cmd->ld->runes->next_unique_id + 1; + db_set_intvar(cmd->ld->wallet->db, "runes_uniqueid", cmd->ld->runes->next_unique_id); + return reply_with_rune(cmd, NULL, NULL, ras->rune); +} + +static const struct json_command creatrune_command = { + "createrune", + "utility", + json_createrune, + "Create or restrict an optional {rune} with optional {restrictions} and returns {rune}" +}; +AUTODATA(json_command, &creatrune_command); + +static void blacklist_merge(struct rune_blacklist *blacklist, + const struct rune_blacklist *entry) +{ + if (entry->start < blacklist->start) { + blacklist->start = entry->start; + } + if (entry->end > blacklist->end) { + blacklist->end = entry->end; + } +} + +static bool blacklist_before(const struct rune_blacklist *first, + const struct rune_blacklist *second) +{ + // Is it before with a gap + return (first->end + 1) < second->start; +} + +static struct command_result *list_blacklist(struct command *cmd) +{ + struct json_stream *js = json_stream_success(cmd); + json_array_start(js, "blacklist"); + for (size_t i = 0; i < tal_count(cmd->ld->runes->blacklist); i++) { + json_object_start(js, NULL); + json_add_u64(js, "start", cmd->ld->runes->blacklist[i].start); + json_add_u64(js, "end", cmd->ld->runes->blacklist[i].end); + json_object_end(js); + } + json_array_end(js); + return command_success(cmd, js); +} + +static struct command_result *json_blacklistrune(struct command *cmd, + const char *buffer, + const jsmntok_t *obj UNNEEDED, + const jsmntok_t *params) +{ + u64 *start, *end; + struct rune_blacklist *entry, *newblacklist; + + if (!param(cmd, buffer, params, + p_opt("start", param_u64, &start), p_opt("end", param_u64, &end), NULL)) + return command_param_failed(); + + if (end && !start) { + return command_fail(cmd, JSONRPC2_INVALID_PARAMS, "Can not specify end without start"); + } + if (!start) { + return list_blacklist(cmd); + } + if (!end) { + end = start; + } + entry = tal(cmd, struct rune_blacklist); + entry->start = *start; + entry->end = *end; + + newblacklist = tal_arr(cmd->ld->runes, struct rune_blacklist, 0); + + for (size_t i = 0; i < tal_count(cmd->ld->runes->blacklist); i++) { + /* if new entry if already merged just copy the old list */ + if (entry == NULL) { + tal_arr_expand(&newblacklist, cmd->ld->runes->blacklist[i]); + continue; + } + /* old list has not reached the entry yet, so we are just copying it */ + if (blacklist_before(&(cmd->ld->runes->blacklist)[i], entry)) { + tal_arr_expand(&newblacklist, cmd->ld->runes->blacklist[i]); + continue; + } + /* old list has passed the entry, time to put the entry in */ + if (blacklist_before(entry, &(cmd->ld->runes->blacklist)[i])) { + tal_arr_expand(&newblacklist, *entry); + tal_arr_expand(&newblacklist, cmd->ld->runes->blacklist[i]); + wallet_insert_blacklist(cmd->ld->wallet, entry); + // mark entry as copied + entry = NULL; + continue; + } + /* old list overlaps combined into the entry we are adding */ + blacklist_merge(entry, &(cmd->ld->runes->blacklist)[i]); + wallet_delete_blacklist(cmd->ld->wallet, &(cmd->ld->runes->blacklist)[i]); + } + if (entry != NULL) { + tal_arr_expand(&newblacklist, *entry); + wallet_insert_blacklist(cmd->ld->wallet, entry); + } + + tal_free(cmd->ld->runes->blacklist); + cmd->ld->runes->blacklist = newblacklist; + return list_blacklist(cmd); +} + +static const struct json_command blacklistrune_command = { + "blacklistrune", + "utility", + json_blacklistrune, + "Blacklist a rune or range of runes by taking an optional {start} and an optional {end} and returns {blacklist} array containing {start}, {end}" +}; +AUTODATA(json_command, &blacklistrune_command); + +static const char *check_condition(const tal_t *ctx, + const struct rune *rune, + const struct rune_altern *alt, + struct cond_info *cinfo) +{ + const jsmntok_t *ptok; + + if (streq(alt->fieldname, "time")) { + return rune_alt_single_int(ctx, alt, time_now().ts.tv_sec); + } else if (streq(alt->fieldname, "id")) { + const char *id = node_id_to_hexstr(tmpctx, cinfo->peer); + return rune_alt_single_str(ctx, alt, id, strlen(id)); + } else if (streq(alt->fieldname, "method")) { + return rune_alt_single_str(ctx, alt, + cinfo->method, strlen(cinfo->method)); + } else if (streq(alt->fieldname, "pnum")) { + return rune_alt_single_int(ctx, alt, (cinfo && cinfo->params) ? cinfo->params->size : 0); + } else if (streq(alt->fieldname, "rate")) { + return rate_limit_check(ctx, cinfo->runes, rune, alt, cinfo); + } + + /* Rest are params looksup: generate this once! */ + if (cinfo->params && strmap_empty(&cinfo->cached_params)) { + const jsmntok_t *t; + size_t i; + + if (cinfo->params->type == JSMN_OBJECT) { + json_for_each_obj(i, t, cinfo->params) { + char *pmemname = tal_fmt(tmpctx, + "pname%.*s", + t->end - t->start, + cinfo->buf + t->start); + size_t off = strlen("pname"); + /* Remove punctuation! */ + for (size_t n = off; pmemname[n]; n++) { + if (cispunct(pmemname[n])) + continue; + pmemname[off++] = pmemname[n]; + } + pmemname[off++] = '\0'; + strmap_add(&cinfo->cached_params, pmemname, t+1); + } + } else if (cinfo->params->type == JSMN_ARRAY) { + json_for_each_arr(i, t, cinfo->params) { + char *pmemname = tal_fmt(tmpctx, "parr%zu", i); + strmap_add(&cinfo->cached_params, pmemname, t); + } + } + } + + ptok = strmap_get(&cinfo->cached_params, alt->fieldname); + if (!ptok) + return rune_alt_single_missing(ctx, alt); + + /* Pass through valid integers as integers. */ + if (ptok->type == JSMN_PRIMITIVE) { + s64 val; + + if (json_to_s64(cinfo->buf, ptok, &val)) { + return rune_alt_single_int(ctx, alt, val); + } + + /* Otherwise, treat it as a string (< and > will fail with + * "is not an integer field") */ + } + return rune_alt_single_str(ctx, alt, + cinfo->buf + ptok->start, + ptok->end - ptok->start); +} + +static struct command_result *json_checkrune(struct command *cmd, + const char *buffer, + const jsmntok_t *obj UNNEEDED, + const jsmntok_t *params) +{ + const jsmntok_t *methodparams; + struct cond_info cinfo; + struct rune_and_string *ras; + struct node_id *nodeid; + struct json_stream *js; + const char *err, *method; + + if (!param(cmd, buffer, params, + p_req("rune", param_rune, &ras), + p_req("nodeid", param_node_id, &nodeid), + p_req("method", param_string, &method), + p_opt("params", param_params, &methodparams), + NULL)) + return command_param_failed(); + + if (is_rune_blacklisted(cmd->ld->runes, ras->rune)) + return command_fail(cmd, RUNE_BLACKLISTED, "Not authorized: Blacklisted rune"); + + cinfo.runes = cmd->ld->runes; + cinfo.peer = nodeid; + cinfo.buf = buffer; + cinfo.method = method; + cinfo.params = methodparams; + /* We will populate it in rate_limit_check if required. */ + cinfo.usage = NULL; + strmap_init(&cinfo.cached_params); + + err = rune_is_derived(cmd->ld->runes->master, ras->rune); + if (err) { + return command_fail(cmd, RUNE_NOT_AUTHORIZED, "Not authorized: %s", err); + } + + err = rune_test(tmpctx, cmd->ld->runes->master, ras->rune, check_condition, &cinfo); + strmap_clear(&cinfo.cached_params); + + /* Just in case they manage to make us speak non-JSON, escape! */ + if (err) { + err = json_escape(tmpctx, err)->s; + return command_fail(cmd, RUNE_NOT_PERMITTED, "Not permitted: %s", err); + } + + /* If it succeeded, *now* we increment any associated usage counter. */ + if (cinfo.usage) + cinfo.usage->counter++; + + js = json_stream_success(cmd); + json_add_bool(js, "valid", true); + return command_success(cmd, js); +} + +static const struct json_command checkrune_command = { + "checkrune", + "utility", + json_checkrune, + "Checks rune for validity with required {nodeid}, {rune}, {method} and optional {params} and returns {valid: true} or error message" +}; +AUTODATA(json_command, &checkrune_command); diff --git a/lightningd/runes.h b/lightningd/runes.h new file mode 100644 index 000000000000..d38052b44464 --- /dev/null +++ b/lightningd/runes.h @@ -0,0 +1,7 @@ +#ifndef LIGHTNING_LIGHTNINGD_RUNES_H +#define LIGHTNING_LIGHTNINGD_RUNES_H +#include "config.h" + +struct runes *runes_init(struct lightningd *ld); + +#endif /* LIGHTNING_LIGHTNINGD_RUNES_H */ diff --git a/lightningd/test/run-find_my_abspath.c b/lightningd/test/run-find_my_abspath.c index f43d3f6608c0..b33f3ce50e65 100644 --- a/lightningd/test/run-find_my_abspath.c +++ b/lightningd/test/run-find_my_abspath.c @@ -187,6 +187,9 @@ void plugins_set_builtin_plugins_dir(struct plugins *plugins UNNEEDED, /* Generated stub for resend_closing_transactions */ void resend_closing_transactions(struct lightningd *ld UNNEEDED) { fprintf(stderr, "resend_closing_transactions called!\n"); abort(); } +/* Generated stub for runes_init */ +struct runes *runes_init(struct lightningd *ld UNNEEDED) +{ fprintf(stderr, "runes_init called!\n"); abort(); } /* Generated stub for setup_color_and_alias */ void setup_color_and_alias(struct lightningd *ld UNNEEDED) { fprintf(stderr, "setup_color_and_alias called!\n"); abort(); } diff --git a/tests/test_runes.py b/tests/test_runes.py new file mode 100644 index 000000000000..bb8a4829cefc --- /dev/null +++ b/tests/test_runes.py @@ -0,0 +1,423 @@ +from fixtures import * # noqa: F401,F403 +from pyln.client import RpcError +import base64 +import pytest +import time + + +def test_createrune(node_factory): + l1 = node_factory.get_node() + + # l1's master rune secret is edb8893c04fdeef8f5f06ed70edef309a5c83f20624594e136e392504a270c40 + rune1 = l1.rpc.createrune() + assert rune1['rune'] == 'OSqc7ixY6F-gjcigBfxtzKUI54uzgFSA6YfBQoWGDV89MA==' + assert rune1['unique_id'] == '0' + rune2 = l1.rpc.createrune(restrictions="readonly") + assert rune2['rune'] == 'zm0x_eLgHexaTvZn3Cz7gb_YlvrlYGDo_w4BYlR9SS09MSZtZXRob2RebGlzdHxtZXRob2ReZ2V0fG1ldGhvZD1zdW1tYXJ5Jm1ldGhvZC9saXN0ZGF0YXN0b3Jl' + assert rune2['unique_id'] == '1' + rune3 = l1.rpc.createrune(restrictions=[["time>1656675211"]]) + assert rune3['rune'] == 'mxHwVsC_W-PH7r79wXQWqxBNHaHncIqIjEPyP_vGOsE9MiZ0aW1lPjE2NTY2NzUyMTE=' + assert rune3['unique_id'] == '2' + rune4 = l1.rpc.createrune(restrictions=[["id^022d223620a359a47ff7"], ["method=listpeers"]]) + assert rune4['rune'] == 'YPojv9qgHPa3im0eiqRb-g8aRq76OasyfltGGqdFUOU9MyZpZF4wMjJkMjIzNjIwYTM1OWE0N2ZmNyZtZXRob2Q9bGlzdHBlZXJz' + assert rune4['unique_id'] == '3' + rune5 = l1.rpc.commando_rune(rune4['rune'], [["pnamelevel!", "pnamelevel/io"]]) + assert rune5['rune'] == 'Zm7A2mKkLnd5l6Er_OMAHzGKba97ij8lA-MpNYMw9nk9MyZpZF4wMjJkMjIzNjIwYTM1OWE0N2ZmNyZtZXRob2Q9bGlzdHBlZXJzJnBuYW1lbGV2ZWwhfHBuYW1lbGV2ZWwvaW8=' + assert rune5['unique_id'] == '3' + rune6 = l1.rpc.commando_rune(rune5['rune'], [["parr1!", "parr1/io"]]) + assert rune6['rune'] == 'm_tyR0qqHUuLEbFJW6AhmBg-9npxVX2yKocQBFi9cvY9MyZpZF4wMjJkMjIzNjIwYTM1OWE0N2ZmNyZtZXRob2Q9bGlzdHBlZXJzJnBuYW1lbGV2ZWwhfHBuYW1lbGV2ZWwvaW8mcGFycjEhfHBhcnIxL2lv' + assert rune6['unique_id'] == '3' + rune7 = l1.rpc.createrune(restrictions=[["pnum=0"]]) + assert rune7['rune'] == 'enX0sTpHB8y1ktyTAF80CnEvGetG340Ne3AGItudBS49NCZwbnVtPTA=' + assert rune7['unique_id'] == '4' + rune8 = l1.rpc.createrune(rune7['rune'], [["rate=3"]]) + assert rune8['rune'] == '_h2eKjoK7ITAF-JQ1S5oum9oMQesrz-t1FR9kDChRB49NCZwbnVtPTAmcmF0ZT0z' + assert rune8['unique_id'] == '4' + rune9 = l1.rpc.createrune(rune8['rune'], [["rate=1"]]) + assert rune9['rune'] == 'U1GDXqXRvfN1A4WmDVETazU9YnvMsDyt7WwNzpY0khE9NCZwbnVtPTAmcmF0ZT0zJnJhdGU9MQ==' + assert rune9['unique_id'] == '4' + + # Test rune with \|. + weirdrune = l1.rpc.createrune(restrictions=[["method=invoice"], + ["pnamedescription=@tipjar|jb55@sendsats.lol"]]) + + with pytest.raises(RpcError, match='Not permitted:') as exc_info: + l1.rpc.checkrune(nodeid=l1.info['id'], + rune=weirdrune['rune'], + method='invoice', + params={"amount_msat": "any", + "label": "lbl", + "description": "@tipjar\\|jb55@sendsats.lol"}) + assert exc_info.value.error['code'] == 0x5de + + assert l1.rpc.checkrune(nodeid=l1.info['id'], + rune=weirdrune['rune'], + method='invoice', + params={"amount_msat": "any", + "label": "lbl", + "description": "@tipjar|jb55@sendsats.lol"})['valid'] is True + + runedecodes = ((rune1, []), + (rune2, [{'alternatives': ['method^list', 'method^get', 'method=summary'], + 'summary': "method (of command) starts with 'list' OR method (of command) starts with 'get' OR method (of command) equal to 'summary'"}, + {'alternatives': ['method/listdatastore'], + 'summary': "method (of command) unequal to 'listdatastore'"}]), + (rune4, [{'alternatives': ['id^022d223620a359a47ff7'], + 'summary': "id (of commanding peer) starts with '022d223620a359a47ff7'"}, + {'alternatives': ['method=listpeers'], + 'summary': "method (of command) equal to 'listpeers'"}]), + (rune5, [{'alternatives': ['id^022d223620a359a47ff7'], + 'summary': "id (of commanding peer) starts with '022d223620a359a47ff7'"}, + {'alternatives': ['method=listpeers'], + 'summary': "method (of command) equal to 'listpeers'"}, + {'alternatives': ['pnamelevel!', 'pnamelevel/io'], + 'summary': "pnamelevel (object parameter 'level') is missing OR pnamelevel (object parameter 'level') unequal to 'io'"}]), + (rune6, [{'alternatives': ['id^022d223620a359a47ff7'], + 'summary': "id (of commanding peer) starts with '022d223620a359a47ff7'"}, + {'alternatives': ['method=listpeers'], + 'summary': "method (of command) equal to 'listpeers'"}, + {'alternatives': ['pnamelevel!', 'pnamelevel/io'], + 'summary': "pnamelevel (object parameter 'level') is missing OR pnamelevel (object parameter 'level') unequal to 'io'"}, + {'alternatives': ['parr1!', 'parr1/io'], + 'summary': "parr1 (array parameter #1) is missing OR parr1 (array parameter #1) unequal to 'io'"}]), + (rune7, [{'alternatives': ['pnum=0'], + 'summary': "pnum (number of command parameters) equal to 0"}]), + (rune8, [{'alternatives': ['pnum=0'], + 'summary': "pnum (number of command parameters) equal to 0"}, + {'alternatives': ['rate=3'], + 'summary': "rate (max per minute) equal to 3"}]), + (rune9, [{'alternatives': ['pnum=0'], + 'summary': "pnum (number of command parameters) equal to 0"}, + {'alternatives': ['rate=3'], + 'summary': "rate (max per minute) equal to 3"}, + {'alternatives': ['rate=1'], + 'summary': "rate (max per minute) equal to 1"}])) + for decode in runedecodes: + rune = decode[0] + restrictions = decode[1] + decoded = l1.rpc.decode(rune['rune']) + assert decoded['type'] == 'rune' + assert decoded['unique_id'] == rune['unique_id'] + assert decoded['valid'] is True + assert decoded['restrictions'] == restrictions + + # Time handling is a bit special, since we annotate the timestamp with how far away it is. + decoded = l1.rpc.decode(rune3['rune']) + assert decoded['type'] == 'rune' + assert decoded['unique_id'] == rune3['unique_id'] + assert decoded['valid'] is True + assert len(decoded['restrictions']) == 1 + assert decoded['restrictions'][0]['alternatives'] == ['time>1656675211'] + assert decoded['restrictions'][0]['summary'].startswith("time (in seconds since 1970) greater than 1656675211 (") + + # Replace rune3 with a more useful timestamp! + expiry = int(time.time()) + 15 + rune3 = l1.rpc.createrune(restrictions=[["time<{}".format(expiry)]]) + + successes = ((rune1, "listpeers", {}), + (rune2, "listpeers", {}), + (rune2, "getinfo", {}), + (rune2, "getinfo", {}), + (rune3, "getinfo", {}), + (rune7, "listpeers", []), + (rune7, "getinfo", {}), + (rune9, "getinfo", {}), + (rune8, "getinfo", {}), + (rune8, "getinfo", {})) + + failures = ((rune2, "withdraw", {}), + (rune2, "plugin", {'subcommand': 'list'}), + (rune3, "getinfo", {}), + (rune4, "listnodes", {}), + (rune5, "listpeers", {'id': l1.info['id'], 'level': 'io'}), + (rune6, "listpeers", [l1.info['id'], 'io']), + (rune7, "listpeers", [l1.info['id']]), + (rune7, "listpeers", {'id': l1.info['id']})) + + for rune, cmd, params in successes: + l1.rpc.checkrune(nodeid=l1.info['id'], + rune=rune['rune'], + method=cmd, + params=params)['valid'] is True + + while time.time() < expiry: + time.sleep(1) + + for rune, cmd, params in failures: + print("{} {}".format(cmd, params)) + with pytest.raises(RpcError, match='Not permitted:') as exc_info: + l1.rpc.checkrune(nodeid=l1.info['id'], + rune=rune['rune'], + method=cmd, + params=params) + assert exc_info.value.error['code'] == 0x5de + + # Now, this can flake if we cross a minute boundary! So wait until + # It succeeds again. + while True: + try: + l1.rpc.checkrune(nodeid=l1.info['id'], + rune=rune8['rune'], + method='getinfo') + break + except RpcError as e: + assert e.error['code'] == 0x5de + time.sleep(1) + + # This fails immediately, since we've done one. + with pytest.raises(RpcError, match='Not permitted:') as exc_info: + l1.rpc.checkrune(nodeid=l1.info['id'], + rune=rune9['rune'], + method='getinfo', + params={}) + assert exc_info.value.error['code'] == 0x5de + + # Two more succeed for rune8. + for _ in range(2): + l1.rpc.checkrune(nodeid=l1.info['id'], + rune=rune8['rune'], + method='getinfo', + params={}) + assert exc_info.value.error['code'] == 0x5de + + # Now we've had 3 in one minute, this will fail. + with pytest.raises(RpcError, match='Not permitted:') as exc_info: + l1.rpc.checkrune(nodeid=l1.info['id'], + rune=rune8['rune'], + method='getinfo', + params={}) + assert exc_info.value.error['code'] == 0x5de + + # rune5 can only be used by l2: + with pytest.raises(RpcError, match='Not permitted:') as exc_info: + l1.rpc.checkrune(nodeid=l1.info['id'], + rune=rune5['rune'], + method="listpeers", + params={}) + assert exc_info.value.error['code'] == 0x5de + + # Now wait for ratelimit expiry, ratelimits should reset. + time.sleep(61) + + for rune, cmd, params in ((rune9, "getinfo", {}), + (rune8, "getinfo", {}), + (rune8, "getinfo", {})): + assert l1.rpc.checkrune(nodeid=l1.info['id'], + rune=rune['rune'], + method=cmd, + params=params)['valid'] is True + + +def test_listrunes(node_factory): + l1 = node_factory.get_node() + rune1 = l1.rpc.createrune() + assert rune1 == { + 'rune': 'OSqc7ixY6F-gjcigBfxtzKUI54uzgFSA6YfBQoWGDV89MA==', + 'unique_id': '0', + 'warning_unrestricted_rune': 'WARNING: This rune has no restrictions! Anyone who has access to this rune could drain funds from your node. Be careful when giving this to apps that you don\'t trust. Consider using the restrictions parameter to only allow access to specific rpc methods.' + } + listrunes = l1.rpc.listrunes() + assert len(l1.rpc.listrunes()) == 1 + l1.rpc.createrune() + listrunes = l1.rpc.listrunes() + assert len(listrunes['runes']) == 2 + assert listrunes == { + 'runes': [ + { + 'rune': 'OSqc7ixY6F-gjcigBfxtzKUI54uzgFSA6YfBQoWGDV89MA==', + 'unique_id': '0', + 'restrictions': [], + 'restrictions_as_english': '' + }, + { + 'rune': 'geZmO6U7yqpHn-moaX93FVMVWrDRfSNY4AXx9ypLcqg9MQ==', + 'unique_id': '1', + 'restrictions': [], + 'restrictions_as_english': '' + } + ] + } + + our_unstored_rune = l1.rpc.listrunes(rune='lI6iPwM1R9OkcRW25SH0a06PscPDinTfLFAjzSGFGE09OQ==')['runes'][0] + assert our_unstored_rune['unique_id'] == '9' + assert our_unstored_rune['stored'] is False + + not_our_rune = l1.rpc.listrunes(rune='oNJAqigqDrHBGzsm7gV3z87oGpzq-KqFlOxx2O9iEQk9MA==')['runes'][0] + assert not_our_rune['stored'] is False + assert not_our_rune['our_rune'] is False + + +def test_blacklistrune(node_factory): + l1 = node_factory.get_node() + + rune0 = l1.rpc.createrune() + assert rune0['unique_id'] == '0' + rune1 = l1.rpc.createrune() + assert rune1['unique_id'] == '1' + + # Make sure runes work! + assert l1.rpc.call(method='checkrune', + payload={'nodeid': l1.info['id'], + 'rune': rune0['rune'], + 'method': 'getinfo'})['valid'] is True + + assert l1.rpc.call(method='checkrune', + payload={'nodeid': l1.info['id'], + 'rune': rune1['rune'], + 'method': 'getinfo'})['valid'] is True + + blacklist = l1.rpc.blacklistrune(start=1) + assert blacklist == {'blacklist': [{'start': 1, 'end': 1}]} + + # Make sure rune id 1 does not work! + with pytest.raises(RpcError, match='Not authorized: Blacklisted rune') as exc_info: + l1.rpc.call(method='checkrune', + payload={'nodeid': l1.info['id'], + 'rune': rune1['rune'], + 'method': 'getinfo'}) + assert exc_info.value.error['code'] == 0x5df + + # But, other rune still works! + assert l1.rpc.call(method='checkrune', + payload={'nodeid': l1.info['id'], + 'rune': rune0['rune'], + 'method': 'getinfo'})['valid'] is True + + blacklist = l1.rpc.blacklistrune(start=2) + assert blacklist == {'blacklist': [{'start': 1, 'end': 2}]} + + blacklist = l1.rpc.blacklistrune(start=6) + assert blacklist == {'blacklist': [{'start': 1, 'end': 2}, + {'start': 6, 'end': 6}]} + + blacklist = l1.rpc.blacklistrune(start=3, end=5) + assert blacklist == {'blacklist': [{'start': 1, 'end': 6}]} + + blacklist = l1.rpc.blacklistrune(start=9) + assert blacklist == {'blacklist': [{'start': 1, 'end': 6}, + {'start': 9, 'end': 9}]} + + blacklist = l1.rpc.blacklistrune(start=0) + assert blacklist == {'blacklist': [{'start': 0, 'end': 6}, + {'start': 9, 'end': 9}]} + + # # Now both runes fail! + with pytest.raises(RpcError, match='Not authorized: Blacklisted rune') as exc_info: + l1.rpc.call(method='checkrune', + payload={'nodeid': l1.info['id'], + 'rune': rune0['rune'], + 'method': 'getinfo'}) + assert exc_info.value.error['code'] == 0x5df + + with pytest.raises(RpcError, match='Not authorized: Blacklisted rune') as exc_info: + l1.rpc.call(method='checkrune', + payload={'nodeid': l1.info['id'], + 'rune': rune1['rune'], + 'method': 'getinfo'}) + assert exc_info.value.error['code'] == 0x5df + + blacklist = l1.rpc.blacklistrune() + assert blacklist == {'blacklist': [{'start': 0, 'end': 6}, + {'start': 9, 'end': 9}]} + + blacklisted_rune = l1.rpc.listrunes(rune='geZmO6U7yqpHn-moaX93FVMVWrDRfSNY4AXx9ypLcqg9MQ==')['runes'][0]['blacklisted'] + assert blacklisted_rune is True + + +def test_badrune(node_factory): + """Test invalid UTF-8 encodings in rune: used to make us kill the offers plugin which implements decode, as it gave bad utf8!""" + l1 = node_factory.get_node() + l1.rpc.decode('5zi6-ugA6hC4_XZ0R7snl5IuiQX4ugL4gm9BQKYaKUU9gCZtZXRob2RebGlzdHxtZXRob2ReZ2V0fG1ldGhvZD1zdW1tYXJ5Jm1ldGhvZC9saXN0ZGF0YXN0b3Jl') + rune = l1.rpc.createrune(restrictions="readonly") + + binrune = base64.urlsafe_b64decode(rune['rune']) + # Mangle each part, try decode. Skip most of the boring chars + # (just '|', '&', '#'). + for i in range(32, len(binrune)): + for span in (range(0, 32), (124, 38, 35), range(127, 256)): + for c in span: + modrune = binrune[:i] + bytes([c]) + binrune[i + 1:] + try: + l1.rpc.decode(base64.urlsafe_b64encode(modrune).decode('utf8')) + except RpcError: + pass + + +def test_checkrune(node_factory): + l1 = node_factory.get_node() + rune1 = l1.rpc.createrune() + rune2 = l1.rpc.createrune(restrictions="readonly") + + res1 = l1.rpc.checkrune(nodeid=l1.info['id'], + rune=rune1['rune'], + method='invoice', + params={'amount_msat': '10000'}) + + assert res1['valid'] is True + + with pytest.raises(RpcError, match='Not permitted:') as exc_info: + l1.rpc.call(method='checkrune', + payload={'nodeid': l1.info['id'], + 'rune': rune2['rune'], + 'method': 'invoice', + 'params': {"amount_msat": "1000", "label": "lbl", "description": "tipjar"}}) + assert exc_info.value.error['code'] == 0x5de + + +def test_rune_pay_amount(node_factory): + l1, l2 = node_factory.line_graph(2) + + # This doesn't really work, since amount_msat is illegal if invoice + # includes an amount, and runes aren't smart enough to decode bolt11! + rune = l1.rpc.createrune(restrictions=[['method=pay'], + ['pnameamountmsat<10000']])['rune'] + + inv1 = l2.rpc.invoice(amount_msat=12300, label='inv1', description='description1')['bolt11'] + inv2 = l2.rpc.invoice(amount_msat='any', label='inv2', description='description2')['bolt11'] + + # Rune requires amount_msat < 10,000! + with pytest.raises(RpcError, match='Not permitted:') as exc_info: + l1.rpc.checkrune(nodeid=l1.info['id'], + rune=rune, + method='pay', + params={'bolt11': inv1}) + assert exc_info.value.error['code'] == 0x5de + + # As a named parameter! + with pytest.raises(RpcError, match='Not permitted:') as exc_info: + l1.rpc.checkrune(nodeid=l1.info['id'], + rune=rune, + method='pay', + params=[inv1]) + assert exc_info.value.error['code'] == 0x5de + + # Can't get around it this way! + with pytest.raises(RpcError, match='Not permitted:') as exc_info: + l1.rpc.checkrune(nodeid=l1.info['id'], + rune=rune, + method='pay', + params=[inv2, 12000]) + assert exc_info.value.error['code'] == 0x5de + + # Nor this way, using a string! + with pytest.raises(RpcError, match='Not permitted:') as exc_info: + l1.rpc.checkrune(nodeid=l1.info['id'], + rune=rune, + method='pay', + params={'bolt11': inv2, 'amount_msat': '10000sat'}) + assert exc_info.value.error['code'] == 0x5de + + # Too much! + with pytest.raises(RpcError, match='Not permitted:') as exc_info: + l1.rpc.checkrune(nodeid=l1.info['id'], + rune=rune, + method='pay', + params={'bolt11': inv2, 'amount_msat': 12000}) + assert exc_info.value.error['code'] == 0x5de + + # This works + res = l1.rpc.checkrune(nodeid=l1.info['id'], + rune=rune, + method='pay', + params={'bolt11': inv2, 'amount_msat': 9999}) + assert res['valid'] is True diff --git a/wallet/db.c b/wallet/db.c index 986f9d58dd38..ecc98232148a 100644 --- a/wallet/db.c +++ b/wallet/db.c @@ -956,6 +956,8 @@ static struct migration dbmigrations[] = { {NULL, migrate_fill_in_channel_type}, {SQL("ALTER TABLE peers ADD feature_bits BLOB DEFAULT NULL;"), NULL}, {NULL, migrate_normalize_invstr}, + {SQL("CREATE TABLE runes (id BIGSERIAL, rune TEXT, PRIMARY KEY (id));"), NULL}, + {SQL("CREATE TABLE runes_blacklist (start_index BIGINT, end_index BIGINT);"), NULL}, }; /** diff --git a/wallet/wallet.c b/wallet/wallet.c index 09ee27f7753b..1446bcc4c18c 100644 --- a/wallet/wallet.c +++ b/wallet/wallet.c @@ -5465,3 +5465,94 @@ struct wallet_htlc_iter *wallet_htlcs_next(struct wallet *w, *hstate = db_col_int(iter->stmt, "h.hstate"); return iter; } + +struct rune_blacklist *wallet_get_runes_blacklist(const tal_t *ctx, struct wallet *wallet) +{ + struct db_stmt *stmt; + struct rune_blacklist *blist = tal_arr(ctx, struct rune_blacklist, 0); + + stmt = db_prepare_v2(wallet->db, SQL("SELECT start_index, end_index FROM runes_blacklist ORDER BY start_index ASC")); + db_query_prepared(stmt); + + while (db_step(stmt)) { + struct rune_blacklist b; + b.start = db_col_u64(stmt, "start_index"); + b.end = db_col_u64(stmt, "end_index"); + tal_arr_expand(&blist, b); + } + tal_free(stmt); + return blist; +} + +const char *wallet_get_rune(const tal_t *ctx, struct wallet *wallet, u64 unique_id) +{ + struct db_stmt *stmt; + const char *runestr; + + stmt = db_prepare_v2(wallet->db, SQL("SELECT rune FROM runes WHERE id = ?")); + db_bind_u64(stmt, unique_id); + db_query_prepared(stmt); + + if (db_step(stmt)) + runestr = db_col_strdup(ctx, stmt, "rune"); + else + runestr = NULL; + tal_free(stmt); + return runestr; +} + +const char **wallet_get_runes(const tal_t *ctx, struct wallet *wallet) +{ + struct db_stmt *stmt; + const char **strs = tal_arr(ctx, const char *, 0); + + stmt = db_prepare_v2(wallet->db, SQL("SELECT rune FROM runes")); + db_query_prepared(stmt); + + while (db_step(stmt)) { + const char *str = db_col_strdup(strs, stmt, "rune"); + tal_arr_expand(&strs, str); + } + tal_free(stmt); + return strs; +} + +void wallet_rune_insert(struct wallet *wallet, struct rune *rune) +{ + struct db_stmt *stmt; + + assert(rune->unique_id != NULL); + + stmt = db_prepare_v2(wallet->db, + SQL("INSERT INTO runes (rune) VALUES (?);")); + db_bind_text(stmt, rune_to_base64(tmpctx, rune)); + db_exec_prepared_v2(stmt); + tal_free(stmt); +} + +void wallet_insert_blacklist(struct wallet *wallet, const struct rune_blacklist *entry) +{ + struct db_stmt *stmt; + + stmt = db_prepare_v2(wallet->db, + SQL("INSERT INTO runes_blacklist VALUES (?,?)")); + db_bind_u64(stmt, entry->start); + db_bind_u64(stmt, entry->end); + db_exec_prepared_v2(stmt); + tal_free(stmt); +} + +void wallet_delete_blacklist(struct wallet *wallet, const struct rune_blacklist *entry) +{ + struct db_stmt *stmt; + + stmt = db_prepare_v2(wallet->db, + SQL("DELETE FROM runes_blacklist WHERE start_index = ? AND end_index = ?")); + db_bind_u64(stmt, entry->start); + db_bind_u64(stmt, entry->end); + db_exec_prepared_v2(stmt); + if (db_count_changes(stmt) != 1) { + db_fatal(wallet->db, "Failed to delete from runes_blacklist"); + } + tal_free(stmt); +} diff --git a/wallet/wallet.h b/wallet/wallet.h index 63f43d3bade4..f09e4b4efc5a 100644 --- a/wallet/wallet.h +++ b/wallet/wallet.h @@ -3,6 +3,7 @@ #include "config.h" #include "db.h" +#include #include #include #include @@ -1524,4 +1525,58 @@ struct wally_psbt *psbt_using_utxos(const tal_t *ctx, u32 nlocktime, u32 nsequence, struct wally_psbt *base); + +/** + * Get a particular runestring from the db + * @ctx: tal ctx for return to be tallocated from + * @wallet: the wallet + * @unique_id: the id of the rune. + * + * Returns NULL if it's not found. + */ +const char *wallet_get_rune(const tal_t *ctx, struct wallet *wallet, u64 unique_id); + +/** + * Get every runestring from the db + * @ctx: tal ctx for return to be tallocated from + * @wallet: the wallet + */ +const char **wallet_get_runes(const tal_t *ctx, struct wallet *wallet); + +/** + * wallet_rune_insert -- Insert the newly created rune into the database + * + * @wallet: the wallet to save into + * @rune: the instance to store + */ +void wallet_rune_insert(struct wallet *wallet, struct rune *rune); + +/* Load the runes blacklist */ +struct rune_blacklist { + u64 start, end; +}; + +/** + * Load the blacklist from the db. + * @ctx: tal ctx for return to be tallocated from + * @wallet: the wallet + */ +struct rune_blacklist *wallet_get_runes_blacklist(const tal_t *ctx, struct wallet *wallet); + +/** + * wallet_insert_blacklist -- Insert rune into blacklist + * + * @wallet: the wallet to save into + * @entry: the new entry to insert + */ +void wallet_insert_blacklist(struct wallet *wallet, const struct rune_blacklist *entry); + +/** + * wallet_delete_blacklist -- Delete row from blacklist + * + * @wallet: the wallet to delete from + * @entry: the entry to delete + */ +void wallet_delete_blacklist(struct wallet *wallet, const struct rune_blacklist *entry); + #endif /* LIGHTNING_WALLET_WALLET_H */