Skip to content

Added optional signString() method #1842

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from

Conversation

robwoodgate
Copy link

@robwoodgate robwoodgate commented Mar 17, 2025

Allows Nostr users to sign/authenticate messages for external apps without compromising their private key (nsec).

It opens up a more generic and flexible challenge-response style external authentication method, using the same Schnorr signature mechanism that Nostr uses to sign events natively.

External apps need only understand Schnorr signatures - they do not need to understand Nostr's event structure. This widens interoperability.

A concrete example of where this would be useful is P2PK locking of Cashu ecash tokens to a Nostr pubkey (npub). Decoding the token requires a Scnorr signature on a structured message string. This method would allow a signer to handle It cleanly.

Would close: #292

@greenart7c3
Copy link
Contributor

Can you add the methods in nip 46 and 55 too?
They are called sign_message
The response is the sig hash
Keychat and 0xchat are already using it for ecash stuff

@robwoodgate
Copy link
Author

robwoodgate commented Mar 17, 2025

I've added a signature for the method to NIP-46. I've made it more generic than sign_message... as keeping the signature broadly comparable with NIP-07 is important, and returning all the required elements for validation is more helpful than returning just the sig.

NIP-55 seems to be a primer for specific implementation of the methods defined in NIP-07.
There is no method signature section, I'm not familiar with Android to draft a full implementation example.
This can be backfilled once the NIP-07 / NIP-46 method signatures are approved.

@robwoodgate
Copy link
Author

robwoodgate commented Mar 17, 2025

@fiatjaf - I think defining the broader return object {hash, sig, pubkey} gives the most future flexibility, as it contains all outputs needed to verify sig... as well as do other checks on the signer (eg: pubkey matches expected signer pubkey, hash matches expected message hash).

But as per @greenart7c3 comment above, some apps, including Alby are just returning sig as a string.

e.g. Alby :: async function signSchnorr(sigHash: string) : string;

Would you prefer the object return as drafted, or stick with the unoffically adopted single string return?

@fiatjaf
Copy link
Member

fiatjaf commented Mar 17, 2025

We've had all sorts of discussions in the past about this, I don't see what has changed. In fact NIP-60 has shown that we don't need this at all to use Cashu with Nostr, have you taken a look at that?

Also, any signer is free to implement this as well as they are free to implement other signing algorithms, but none of it is related to Nostr at all so the specification shouldn't live in this repository.

@robwoodgate
Copy link
Author

robwoodgate commented Mar 17, 2025

I understand your position - what's changed is that it's 2025, and Nostr users expect to keep their SK private and use a signer for operations where their Nostr identity is involved. We are (thankfully) moving away from "enter your nsec" type applications.

I agree that adding a plethora of signing algorithms would be undesirable... issue #292 highlighted this floodgate.
However Nostr uses Schnorr as its primary event signing algorithm, so it makes sense to support it in a structured way.

There is already established demand for it (Alby, 0xchat, Keychat, Cashu P2PK), and even though it's an optional, it's important because applications need to know the interface they are working with.

Cheers
Rob

Ps - I have indeed looked at NIP-60. The P2PK unlocking of tokens manually locked to an npub is not related to nutzaps. But that's a side issue... it was simply a concrete example use case for signing.

@robwoodgate
Copy link
Author

Have tweaked it into line with existing signer implementation (Alby) to maximize backwards compatibility with existing apps.
If this optional method can be codified for the reasons above, that would be great. Cheers.

… to avoid backwards incompatibility in the wild
@robwoodgate robwoodgate changed the title Added optional signSchnorr() method Added optional signString() method Mar 19, 2025
@robwoodgate
Copy link
Author

robwoodgate commented Mar 19, 2025

On reflection, I've reversed the attempt to align the proposed method with the existing Alby method signature.

Nostr events are always presented to the user in raw form for signing, and I believe it is safer and more consistent to do the same in this new method, so the user can be sure they are not signing something unintended or malicious.

The Alby Extension signSchnorr() method takes a hash input, not the original message string, and only returns the signature, so to increase flexibility and remove potential backwards incompatibility, I've renamed the proposed method to signString(). This is also consistent with the existing naming convention for signEvent().

The new signString() method takes a string message input for signing (so user can see what is being signed) and returns all the data needed to do checks and verification of what was signed and who it was signed by: { hash: string, sig: string, pubkey: string }. This reduces the potential for abuse of window.nostr.

I believe this presents the most flexible and future-proofed method for signing non-event structured messages.

@robwoodgate
Copy link
Author

robwoodgate commented Mar 19, 2025

For completeness, here's a pseudo-code stub for the method itself:

async function signString(message: string): Object {
    if (typeof message !== 'string') {
        return { error: { message: "message is not a string" } };
    }
    try {
        // Check this is not a stringified event
        const obj = JSON.parse(message);
        if (validateEvent(obj)){
          return { error: { message: "use signEvent() to sign events" } };
        }
    } catch (e) {} // not a JSON string
    const secretKey = <get_sk_from_store>;
    const utf8Encoder = new TextEncoder();
    const hash = bytesToHex(sha256(utf8Encoder.encode(message)));
    const sig = bytesToHex(schnorr.sign(hash, secretKey));
    const pubkey = bytesToHex(schnorr.getPublicKey(secretKey));
    return { hash: hash, sig: sig, pubkey: pubkey };
}

@robwoodgate
Copy link
Author

@fiatjaf - here's another reason why you should add this PR:

You cannot control what you do not specify.

The core of the Nostr protocol is the Schnorr signed Event. That makes Schnorr an integral part of what makes Nostr so brilliant and special. And there is clearly demand for Schnorr signing of messages outside of the event structure.

If you do not protect that by specifying a Schnorr message signing method, you risk undermining NIP-07.

Why? Because a Schnorr signer like the existing Alby signSchnorr() implementation can be used to bypass signEvent() permission checks and sign serialized Nostr events manually. It's totally opaque (takes a hash input), so can be abused in many ways that can hurt Nostr users.

With this PR, you ensure signers use Schnorr in a structured, controlled way.

As you'll see, I've submitted PRs for nos2x and AKA Profiles that protects the core signEvent() method. I've also just updated this PR to add a constraint for signString in NIP-07.

Nature abhors a vacuum. Please reconsider filling this one.

@neilck
Copy link
Contributor

neilck commented Mar 25, 2025

I merged Rob's PR with signString() into AKA Profiles Signing Extension. I haven't been active in the Nostr space for awhile, but the old guidelines were a certain number of implementations before making a change to a NIP. So +1.

I actually agree with @fiatjaf that because its not strictly Nostr related it should not be in the NIP. (He is bombarded with requests, and has to hold the line somewhere). That said, more than happy to continue to add features to my signing extension, and put links in my readme to pull requests that describe new functionality.

@nostrband
Copy link
Collaborator

nostrband commented Mar 26, 2025

NACK.

To ensure signEvent() permission checks are not bypassed, the optional signString() function MUST NOT sign any message that is a valid stringified event.

This is wrong - signEvent signs event id, not it's content, from nip01:

"sig": <64-bytes lowercase hex of the signature of the sha256 hash of the serialized event data, which is the same as the "id" field>

So I can create an event, calculate it's id, sign id with signString and publish the event, bypassing the permissions.

@robwoodgate
Copy link
Author

@nostrband - The signString() method, as specified, computes the hash of the string and signs it. This would NOT result in a valid event, as it would be a hash of a hash.

@nostrband
Copy link
Collaborator

Ok sorry for not reading this deep enough.

This signString method should then be called signStringSha256? Won't there be requests for sha512 tomorrow? Maybe hashing algo should also be provided and method called signStringHash?

@melvincarvalho
Copy link

Alby’s really leading the way here with a solid implementation. I think NIP-07 is in a good spot to be standardized.

There’s growing interest even outside the Nostr space, so maybe it’s time to think about this as a proper web standard. Would be great if browser vendors could eventually support it natively too.

On the Trezor front, maybe we just add a quick Security Considerations section. Could also patch the current extension or spin up a second one to handle that flow.

This opens up all sorts of use cases—it might help to jot a few down to show the range.

But yeah, feels like this needs to happen to get more folks aligned with the great work Alby’s already doing.

@melvincarvalho
Copy link

FWIW: I have implemented a full bitcoin testnet and nostr wallet using alby's amazing signSchnorr function here:

https://testcoin.org/

It allows getting coins from a faucet, proof-of-publication for nostr identities, sending coins to other npubs, and OP_RETURNs. Testing on testnet4 but would just be one param for main net leading to native on-chain payments, zaps and utxo management. signSchnorr is critical, and needs to be done. The alby way works. I would be very hesitant to change anything.

@robwoodgate
Copy link
Author

robwoodgate commented Mar 26, 2025

Ok sorry for not reading this deep enough.

This signString method should then be called signStringSha256? Won't there be requests for sha512 tomorrow? Maybe hashing algo should also be provided and method called signStringHash?

In drafting this PR, I've been pretty conscious to avoid stepping outside core Nostr ecosystem. The concerns of scope-creep are real, and I also agree that Nostr should not support any and all random signers/algos as part of core protocol.

I make an exception for signString() because, as drafted, it uses the same hashing/signing method of signEvent(), and has guard rails to avoid permission avoidance, making it protective to Nostr.

If, in time, Nostr signEvent switches to SHA512 or some other hashing algo, signString would simply follow suit.

The alby way works. I would be very hesitant to change anything.

I'm thrilled that Alby has led the way, and the demand for this type of method is real. However, I believe the current Alby signSchnorr, which takes a hash as input, can be used to undermine the permissions of NIP-07, and is opaque to users.

This is also why I'm keen to formally establish a "safer" method within the NIPs.

Migrating from signSchnorr to signString would be pretty trivial if you are using core Nostr hashing/signing algo. And the response from signString allows you to check a man-in-the-middle has not corrupted or changed the result from the signer, as you have all elements required to double-check.

That said, there is no reason Alby or other signers could not informally support OTHER signing/hashing methods as well. I just think the NIP needs to protect its core algo (Schnorr signed SHA256 hash of UTF8 string).

@haorendashu
Copy link
Contributor

Are there any signer had implemented it? And Are there any client has used it?

Can you show me some use case. I want to test it if i had implemented it.

@robwoodgate
Copy link
Author

robwoodgate commented Apr 12, 2025

Yes, it is I currently implemented by AKA Profiles signer:
https://github.com/neilck/aka-extension

it is pending review in nos2x:
fiatjaf/nos2x#74

it is used in Cashu Redeem:
https://www.nostrly.com/cashu-redeem/

@robwoodgate
Copy link
Author

Cashu Witness also uses it:
https://www.nostrly.com/cashu-witness/

@fiatjaf
Copy link
Member

fiatjaf commented Apr 14, 2025

This is really not a Nostr-related proposal at all.

It would make more sense as window.bip340.signString() or something like that. Existing Nostr extensions could opt to provide such method, but other extensions could also do that. Nothing in it makes it related to Nostr.

@fiatjaf
Copy link
Member

fiatjaf commented Apr 14, 2025

The Cashu-P2PK use case is a valid one, but NIP-60 has already solved it by keeping a different key for the Cashu wallet and sharing it around with every Nostr/NIP-60 client. That same key could be used in P2PK.

@robwoodgate
Copy link
Author

Would supercede #1026

NIP60/61 helps, but does not solve the case where a users Nostr pubkey has been used as the p2pk lock.

With this addition, Nostr becomes the identity layer for countless apps…

Example:
https://x.com/callebtc/status/1912345151730774265?s=46

@callebtc
Copy link

To cover the Cashu P2PK case without exposing users to a risk of signing data they don't agree with, the method could be limited to only signing NUT-10 well-known secret structures that look like this:

[
kind <str>,
  {
    "nonce": <str>,
    "data": <str>,
    "tags": [[ "key", "value1", "value2", ...],  ... ], // (optional)
  }
]

This should still cover all Cashu-related use cases for now and in the future.

@mikedilger
Copy link
Contributor

mikedilger commented May 2, 2025

Delegation (NIP-26, deprecated) requires signing of the hash of the delegation string, and while adding nip-46 support to gossip I ran into the problem that NIP-46 doesn't offer any method to do that. I'm not saying it should, just to be aware that this is/was another use case for signString().

Copy link
Contributor

@Semisol Semisol left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blind signing is not a good idea.

@robwoodgate
Copy link
Author

robwoodgate commented Aug 8, 2025

blind signing is not a good idea.

How is it blind? The raw json is visible to user. That said, am more in support of the NUT-10 specific signSecret for the identified Cashu use cases.

@Semisol
Copy link
Contributor

Semisol commented Aug 8, 2025

blind signing is not a good idea.

How is it blind? The raw json is visible to user. That said, am more in support of the NUT-10 specific signSecret for the identified Cashu use cases.

Anything that is not shown in a format that the user can easily understand the implications of without technical knowledge may as well be blind signing.

@robwoodgate
Copy link
Author

robwoodgate commented Aug 9, 2025

Anything that is not shown in a format that the user can easily understand the implications of without technical knowledge may as well be blind signing.

No difference to a Nostr event. And a signer could “pretty it up“.

That makes this version much “safer” than the leading signString implementation, which shows the user a SHA-256 string to sign.

But again, am now favoring the #1890 PR, which signs NUT-10 format secrets only. This has a specific use case for Cashu integration.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Best way to propose adding window.nostr.signSchnorr()
10 participants