Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c45a4f3dc | ||
|
|
2221e81c9b | ||
|
|
2ca4baad63 | ||
|
|
c4ecc8aca2 | ||
|
|
43c8d0d3a3 | ||
|
|
b1218fa514 | ||
|
|
a20f17ea32 | ||
|
|
bdc9513e52 | ||
|
|
1fd3862324 | ||
|
|
7f1dc00f49 | ||
|
|
973025602c | ||
|
|
7af85a45a7 | ||
|
|
71347ea1bf | ||
|
|
58d9b4386e | ||
|
|
cb4f4b2a3c | ||
|
|
05a5306f86 | ||
|
|
98b87de736 | ||
|
|
ae6f176f52 | ||
|
|
a79277f3ed | ||
|
|
521693cfa1 | ||
|
|
3109a93163 | ||
|
|
4505167246 | ||
|
|
ea387c0c9f | ||
|
|
a7dceb1156 | ||
|
|
966d9d0456 | ||
|
|
ccff136edb | ||
|
|
8f34c2de73 | ||
|
|
ca75df8bb4 | ||
|
|
c747f1f315 | ||
|
|
2a66b5eeec | ||
|
|
fa9688b17e | ||
|
|
a0e18c34d6 | ||
|
|
995c3f526c | ||
|
|
77ea4a8e67 | ||
|
|
12d4810f4c | ||
|
|
517974699d | ||
|
|
bac621bbaa | ||
|
|
9f0b0638e5 | ||
|
|
b59bf17372 | ||
|
|
3b1eb7f951 |
+3
-8
@@ -1,6 +1,6 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
nostr-tools/
|
||||
|
||||
|
||||
# IDE and OS files
|
||||
.idea/
|
||||
@@ -18,10 +18,5 @@ Thumbs.db
|
||||
log.txt
|
||||
Trash/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
|
||||
# Aider files
|
||||
.aider.chat.history.md
|
||||
.aider.input.history
|
||||
.aider.tags.cache.v3/
|
||||
nostr-login/
|
||||
nostr-tools/
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
NIP-17
|
||||
======
|
||||
|
||||
Private Direct Messages
|
||||
-----------------------
|
||||
|
||||
`draft` `optional`
|
||||
|
||||
This NIP defines an encrypted direct messaging scheme using [NIP-44](44.md) encryption and [NIP-59](59.md) seals and gift wraps.
|
||||
|
||||
## Direct Message Kind
|
||||
|
||||
Kind `14` is a chat message. `p` tags identify one or more receivers of the message.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "<usual hash>",
|
||||
"pubkey": "<sender-pubkey>",
|
||||
"created_at": "<current-time>",
|
||||
"kind": 14,
|
||||
"tags": [
|
||||
["p", "<receiver-1-pubkey>", "<relay-url>"],
|
||||
["p", "<receiver-2-pubkey>", "<relay-url>"],
|
||||
["e", "<kind-14-id>", "<relay-url>"] // if this is a reply
|
||||
["subject", "<conversation-title>"],
|
||||
// rest of tags...
|
||||
],
|
||||
"content": "<message-in-plain-text>",
|
||||
}
|
||||
```
|
||||
|
||||
`.content` MUST be plain text. Fields `id` and `created_at` are required.
|
||||
|
||||
An `e` tag denotes the direct parent message this post is replying to.
|
||||
|
||||
`q` tags MAY be used when citing events in the `.content` with [NIP-21](21.md).
|
||||
|
||||
```json
|
||||
["q", "<event-id> or <event-address>", "<relay-url>", "<pubkey-if-a-regular-event>"]
|
||||
```
|
||||
|
||||
Kind `14`s MUST never be signed. If it is signed, the message might leak to relays and become **fully public**.
|
||||
|
||||
## File Message Kind
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "<usual hash>",
|
||||
"pubkey": "<sender-pubkey>",
|
||||
"created_at": "<current-time>",
|
||||
"kind": 15,
|
||||
"tags": [
|
||||
["p", "<receiver-1-pubkey>", "<relay-url>"],
|
||||
["p", "<receiver-2-pubkey>", "<relay-url>"],
|
||||
["e", "<kind-14-id>", "<relay-url>", "reply"], // if this is a reply
|
||||
["subject", "<conversation-title>"],
|
||||
["file-type", "<file-mime-type>"],
|
||||
["encryption-algorithm", "<encryption-algorithm>"],
|
||||
["decryption-key", "<decryption-key>"],
|
||||
["decryption-nonce", "<decryption-nonce>"],
|
||||
["x", "<the SHA-256 hexencoded string of the file>"],
|
||||
// rest of tags...
|
||||
],
|
||||
"content": "<file-url>"
|
||||
}
|
||||
```
|
||||
|
||||
Kind `15` is used for sending encrypted file event messages:
|
||||
|
||||
- `file-type`: Specifies the MIME type of the attached file (e.g., `image/jpeg`, `audio/mpeg`, etc.) before encryption.
|
||||
- `encryption-algorithm`: Indicates the encryption algorithm used for encrypting the file. Supported algorithms: `aes-gcm`.
|
||||
- `decryption-key`: The decryption key that will be used by the recipient to decrypt the file.
|
||||
- `decryption-nonce`: The decryption nonce that will be used by the recipient to decrypt the file.
|
||||
- `content`: The URL of the file (`<file-url>`).
|
||||
- `x` containing the SHA-256 hexencoded string of the encrypted file.
|
||||
- `ox` containing the SHA-256 hexencoded string of the file before encryption.
|
||||
- `size` (optional) size of the encrypted file in bytes
|
||||
- `dim` (optional) size in pixels in the form `<width>x<height>`
|
||||
- `blurhash`(optional) the [blurhash](https://github.com/woltapp/blurhash) to show while the client is loading the file
|
||||
- `thumb` (optional) URL of thumbnail with same aspect ratio (encrypted with the same key, nonce)
|
||||
- `fallback` (optional) zero or more fallback file sources in case `url` fails (encrypted with the same key, nonce)
|
||||
|
||||
Just like kind `14`, kind `15`s MUST never be signed.
|
||||
|
||||
## Chat Rooms
|
||||
|
||||
The set of `pubkey` + `p` tags defines a chat room. If a new `p` tag is added or a current one is removed, a new room is created with a clean message history.
|
||||
|
||||
Clients SHOULD render messages of the same room in a continuous thread.
|
||||
|
||||
An optional `subject` tag defines the current name/topic of the conversation. Any member can change the topic by simply submitting a new `subject` to an existing `pubkey` + `p` tags room. There is no need to send `subject` in every message. The newest `subject` in the chat room is the subject of the conversation.
|
||||
|
||||
## Encrypting
|
||||
|
||||
Following [NIP-59](59.md), the **unsigned** `kind:14` & `kind:15` chat messages must be sealed (`kind:13`) and then gift-wrapped (`kind:1059`) to each receiver and the sender individually.
|
||||
|
||||
```js
|
||||
{
|
||||
"id": "<usual hash>",
|
||||
"pubkey": randomPublicKey,
|
||||
"created_at": randomTimeUpTo2DaysInThePast(),
|
||||
"kind": 1059, // gift wrap
|
||||
"tags": [
|
||||
["p", receiverPublicKey, "<relay-url>"] // receiver
|
||||
],
|
||||
"content": nip44Encrypt(
|
||||
{
|
||||
"id": "<usual hash>",
|
||||
"pubkey": senderPublicKey,
|
||||
"created_at": randomTimeUpTo2DaysInThePast(),
|
||||
"kind": 13, // seal
|
||||
"tags": [], // no tags
|
||||
"content": nip44Encrypt(unsignedKind14, senderPrivateKey, receiverPublicKey),
|
||||
"sig": "<signed by senderPrivateKey>"
|
||||
},
|
||||
randomPrivateKey, receiverPublicKey
|
||||
),
|
||||
"sig": "<signed by randomPrivateKey>"
|
||||
}
|
||||
```
|
||||
|
||||
The encryption algorithm MUST use the latest version of [NIP-44](44.md).
|
||||
|
||||
Clients MUST verify if pubkey of the `kind:13` is the same pubkey on the `kind:14`, otherwise any sender can impersonate others by simply changing the pubkey on `kind:14`.
|
||||
|
||||
Clients SHOULD randomize `created_at` in up to two days in the past in both the seal and the gift wrap to make sure grouping by `created_at` doesn't reveal any metadata.
|
||||
|
||||
The gift wrap's `p` tag can be the receiver's main pubkey or an alias key created to receive DMs without exposing the receiver's identity.
|
||||
|
||||
Clients CAN offer disappearing messages by setting an `expiration` tag in the gift wrap of each receiver or by not generating a gift wrap to the sender's public key
|
||||
|
||||
## Publishing
|
||||
|
||||
Kind `10050` indicates the user's preferred relays to receive DMs. The event MUST include a list of `relay` tags with relay URIs.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 10050,
|
||||
"tags": [
|
||||
["relay", "wss://inbox.nostr.wine"],
|
||||
["relay", "wss://myrelay.nostr1.com"],
|
||||
],
|
||||
"content": "",
|
||||
// other fields...
|
||||
}
|
||||
```
|
||||
|
||||
Clients SHOULD publish kind `14` events to the `10050`-listed relays. If that is not found that indicates the user is not ready to receive messages under this NIP and clients shouldn't try.
|
||||
|
||||
## Relays
|
||||
|
||||
It's advisable that relays do not serve `kind:1059` to clients other than the ones tagged in them.
|
||||
|
||||
It's advisable that users choose relays that conform to these practices.
|
||||
|
||||
Clients SHOULD guide users to keep `kind:10050` lists small (1-3 relays) and SHOULD spread it to as many relays as viable.
|
||||
|
||||
## Benefits & Limitations
|
||||
|
||||
This NIP offers the following privacy and security features:
|
||||
|
||||
1. **No Metadata Leak**: Participant identities, each message's real date and time, event kinds, and other event tags are all hidden from the public. Senders and receivers cannot be linked with public information alone.
|
||||
2. **No Public Group Identifiers**: There is no public central queue, channel or otherwise converging identifier to correlate or count all messages in the same group.
|
||||
3. **No Moderation**: There are no group admins: no invitations or bans.
|
||||
4. **No Shared Secrets**: No secret must be known to all members that can leak or be mistakenly shared
|
||||
5. **Fully Recoverable**: Messages can be fully recoverable by any client with the user's private key
|
||||
6. **Optional Forward Secrecy**: Users and clients can opt-in for "disappearing messages".
|
||||
7. **Uses Public Relays**: Messages can flow through public relays without loss of privacy. Private relays can increase privacy further, but they are not required.
|
||||
8. **Cold Storage**: Users can unilaterally opt-in to sharing their messages with a separate key that is exclusive for DM backup and recovery.
|
||||
|
||||
The main limitation of this approach is having to send a separate encrypted event to each receiver. Group chats with more than 100 participants should find a more suitable messaging scheme.
|
||||
|
||||
## Implementation
|
||||
|
||||
Clients implementing this NIP should by default only connect to the set of relays found in their `kind:10050` list. From that they should be able to load all messages both sent and received as well as get new live updates, making it for a very simple and lightweight implementation that should be fast.
|
||||
|
||||
When sending a message to anyone, clients must then connect to the relays in the receiver's `kind:10050` and send the events there but can disconnect right after unless more messages are expected to be sent (e.g. the chat tab is still selected). Clients should also send a copy of their outgoing messages to their own `kind:10050` relay set.
|
||||
|
||||
## Examples
|
||||
|
||||
This example sends the message `Hola, que tal?` from `nsec1w8udu59ydjvedgs3yv5qccshcj8k05fh3l60k9x57asjrqdpa00qkmr89m` to `nsec12ywtkplvyq5t6twdqwwygavp5lm4fhuang89c943nf2z92eez43szvn4dt`.
|
||||
|
||||
The two final GiftWraps, one to the receiver and the other to the sender, respectively, are:
|
||||
|
||||
```json
|
||||
{
|
||||
"id":"2886780f7349afc1344047524540ee716f7bdc1b64191699855662330bf235d8",
|
||||
"pubkey":"8f8a7ec43b77d25799281207e1a47f7a654755055788f7482653f9c9661c6d51",
|
||||
"created_at":1703128320,
|
||||
"kind":1059,
|
||||
"tags":[
|
||||
["p", "918e2da906df4ccd12c8ac672d8335add131a4cf9d27ce42b3bb3625755f0788"]
|
||||
],
|
||||
"content":"AsqzdlMsG304G8h08bE67dhAR1gFTzTckUUyuvndZ8LrGCvwI4pgC3d6hyAK0Wo9gtkLqSr2rT2RyHlE5wRqbCOlQ8WvJEKwqwIJwT5PO3l2RxvGCHDbd1b1o40ZgIVwwLCfOWJ86I5upXe8K5AgpxYTOM1BD+SbgI5jOMA8tgpRoitJedVSvBZsmwAxXM7o7sbOON4MXHzOqOZpALpS2zgBDXSAaYAsTdEM4qqFeik+zTk3+L6NYuftGidqVluicwSGS2viYWr5OiJ1zrj1ERhYSGLpQnPKrqDaDi7R1KrHGFGyLgkJveY/45y0rv9aVIw9IWF11u53cf2CP7akACel2WvZdl1htEwFu/v9cFXD06fNVZjfx3OssKM/uHPE9XvZttQboAvP5UoK6lv9o3d+0GM4/3zP+yO3C0NExz1ZgFmbGFz703YJzM+zpKCOXaZyzPjADXp8qBBeVc5lmJqiCL4solZpxA1865yPigPAZcc9acSUlg23J1dptFK4n3Tl5HfSHP+oZ/QS/SHWbVFCtq7ZMQSRxLgEitfglTNz9P1CnpMwmW/Y4Gm5zdkv0JrdUVrn2UO9ARdHlPsW5ARgDmzaxnJypkfoHXNfxGGXWRk0sKLbz/ipnaQP/eFJv/ibNuSfqL6E4BnN/tHJSHYEaTQ/PdrA2i9laG3vJti3kAl5Ih87ct0w/tzYfp4SRPhEF1zzue9G/16eJEMzwmhQ5Ec7jJVcVGa4RltqnuF8unUu3iSRTQ+/MNNUkK6Mk+YuaJJs6Fjw6tRHuWi57SdKKv7GGkr0zlBUU2Dyo1MwpAqzsCcCTeQSv+8qt4wLf4uhU9Br7F/L0ZY9bFgh6iLDCdB+4iABXyZwT7Ufn762195hrSHcU4Okt0Zns9EeiBOFxnmpXEslYkYBpXw70GmymQfJlFOfoEp93QKCMS2DAEVeI51dJV1e+6t3pCSsQN69Vg6jUCsm1TMxSs2VX4BRbq562+VffchvW2BB4gMjsvHVUSRl8i5/ZSDlfzSPXcSGALLHBRzy+gn0oXXJ/447VHYZJDL3Ig8+QW5oFMgnWYhuwI5QSLEyflUrfSz+Pdwn/5eyjybXKJftePBD9Q+8NQ8zulU5sqvsMeIx/bBUx0fmOXsS3vjqCXW5IjkmSUV7q54GewZqTQBlcx+90xh/LSUxXex7UwZwRnifvyCbZ+zwNTHNb12chYeNjMV7kAIr3cGQv8vlOMM8ajyaZ5KVy7HpSXQjz4PGT2/nXbL5jKt8Lx0erGXsSsazkdoYDG3U",
|
||||
"sig":"a3c6ce632b145c0869423c1afaff4a6d764a9b64dedaf15f170b944ead67227518a72e455567ca1c2a0d187832cecbde7ed478395ec4c95dd3e71749ed66c480"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id":"162b0611a1911cfcb30f8a5502792b346e535a45658b3a31ae5c178465509721",
|
||||
"pubkey":"626be2af274b29ea4816ad672ee452b7cf96bbb4836815a55699ae402183f512",
|
||||
"created_at":1702711587,
|
||||
"kind":1059,
|
||||
"tags":[
|
||||
["p", "44900586091b284416a0c001f677f9c49f7639a55c3f1e2ec130a8e1a7998e1b"]
|
||||
],
|
||||
"content":"AsTClTzr0gzXXji7uye5UB6LYrx3HDjWGdkNaBS6BAX9CpHa+Vvtt5oI2xJrmWLen+Fo2NBOFazvl285Gb3HSM82gVycrzx1HUAaQDUG6HI7XBEGqBhQMUNwNMiN2dnilBMFC3Yc8ehCJT/gkbiNKOpwd2rFibMFRMDKai2mq2lBtPJF18oszKOjA+XlOJV8JRbmcAanTbEK5nA/GnG3eGUiUzhiYBoHomj3vztYYxc0QYHOx0WxiHY8dsC6jPsXC7f6k4P+Hv5ZiyTfzvjkSJOckel1lZuE5SfeZ0nduqTlxREGeBJ8amOykgEIKdH2VZBZB+qtOMc7ez9dz4wffGwBDA7912NFS2dPBr6txHNxBUkDZKFbuD5wijvonZDvfWq43tZspO4NutSokZB99uEiRH8NAUdGTiNb25m9JcDhVfdmABqTg5fIwwTwlem5aXIy8b66lmqqz2LBzJtnJDu36bDwkILph3kmvaKPD8qJXmPQ4yGpxIbYSTCohgt2/I0TKJNmqNvSN+IVoUuC7ZOfUV9lOV8Ri0AMfSr2YsdZ9ofV5o82ClZWlWiSWZwy6ypa7CuT1PEGHzywB4CZ5ucpO60Z7hnBQxHLiAQIO/QhiBp1rmrdQZFN6PUEjFDloykoeHe345Yqy9Ke95HIKUCS9yJurD+nZjjgOxZjoFCsB1hQAwINTIS3FbYOibZnQwv8PXvcSOqVZxC9U0+WuagK7IwxzhGZY3vLRrX01oujiRrevB4xbW7Oxi/Agp7CQGlJXCgmRE8Rhm+Vj2s+wc/4VLNZRHDcwtfejogjrjdi8p6nfUyqoQRRPARzRGUnnCbh+LqhigT6gQf3sVilnydMRScEc0/YYNLWnaw9nbyBa7wFBAiGbJwO40k39wj+xT6HTSbSUgFZzopxroO3f/o4+ubx2+IL3fkev22mEN38+dFmYF3zE+hpE7jVxrJpC3EP9PLoFgFPKCuctMnjXmeHoiGs756N5r1Mm1ffZu4H19MSuALJlxQR7VXE/LzxRXDuaB2u9days/6muP6gbGX1ASxbJd/ou8+viHmSC/ioHzNjItVCPaJjDyc6bv+gs1NPCt0qZ69G+JmgHW/PsMMeL4n5bh74g0fJSHqiI9ewEmOG/8bedSREv2XXtKV39STxPweceIOh0k23s3N6+wvuSUAJE7u1LkDo14cobtZ/MCw/QhimYPd1u5HnEJvRhPxz0nVPz0QqL/YQeOkAYk7uzgeb2yPzJ6DBtnTnGDkglekhVzQBFRJdk740LEj6swkJ",
|
||||
"sig":"c94e74533b482aa8eeeb54ae72a5303e0b21f62909ca43c8ef06b0357412d6f8a92f96e1a205102753777fd25321a58fba3fb384eee114bd53ce6c06a1c22bab"
|
||||
}
|
||||
```
|
||||
@@ -1,297 +0,0 @@
|
||||
NIP-44
|
||||
======
|
||||
|
||||
Encrypted Payloads (Versioned)
|
||||
------------------------------
|
||||
|
||||
`optional`
|
||||
|
||||
The NIP introduces a new data format for keypair-based encryption. This NIP is versioned
|
||||
to allow multiple algorithm choices to exist simultaneously. This format may be used for
|
||||
many things, but MUST be used in the context of a signed event as described in NIP-01.
|
||||
|
||||
*Note*: this format DOES NOT define any `kind`s related to a new direct messaging standard,
|
||||
only the encryption required to define one. It SHOULD NOT be used as a drop-in replacement
|
||||
for NIP-04 payloads.
|
||||
|
||||
## Versions
|
||||
|
||||
Currently defined encryption algorithms:
|
||||
|
||||
- `0x00` - Reserved
|
||||
- `0x01` - Deprecated and undefined
|
||||
- `0x02` - secp256k1 ECDH, HKDF, padding, ChaCha20, HMAC-SHA256, base64
|
||||
|
||||
## Limitations
|
||||
|
||||
Every nostr user has their own public key, which solves key distribution problems present
|
||||
in other solutions. However, nostr's relay-based architecture makes it difficult to implement
|
||||
more robust private messaging protocols with things like metadata hiding, forward secrecy,
|
||||
and post compromise secrecy.
|
||||
|
||||
The goal of this NIP is to have a _simple_ way to encrypt payloads used in the context of a signed
|
||||
event. When applying this NIP to any use case, it's important to keep in mind your users' threat
|
||||
model and this NIP's limitations. For high-risk situations, users should chat in specialized E2EE
|
||||
messaging software and limit use of nostr to exchanging contacts.
|
||||
|
||||
On its own, messages sent using this scheme have a number of important shortcomings:
|
||||
|
||||
- No deniability: it is possible to prove an event was signed by a particular key
|
||||
- No forward secrecy: when a key is compromised, it is possible to decrypt all previous conversations
|
||||
- No post-compromise security: when a key is compromised, it is possible to decrypt all future conversations
|
||||
- No post-quantum security: a powerful quantum computer would be able to decrypt the messages
|
||||
- IP address leak: user IP may be seen by relays and all intermediaries between user and relay
|
||||
- Date leak: `created_at` is public, since it is a part of NIP-01 event
|
||||
- Limited message size leak: padding only partially obscures true message length
|
||||
- No attachments: they are not supported
|
||||
|
||||
Lack of forward secrecy may be partially mitigated by only sending messages to trusted relays, and asking
|
||||
relays to delete stored messages after a certain duration has elapsed.
|
||||
|
||||
## Version 2
|
||||
|
||||
NIP-44 version 2 has the following design characteristics:
|
||||
|
||||
- Payloads are authenticated using a MAC before signing rather than afterwards because events are assumed
|
||||
to be signed as specified in NIP-01. The outer signature serves to authenticate the full payload, and MUST
|
||||
be validated before decrypting.
|
||||
- ChaCha is used instead of AES because it's faster and has
|
||||
[better security against multi-key attacks](https://datatracker.ietf.org/doc/draft-irtf-cfrg-aead-limits/).
|
||||
- ChaCha is used instead of XChaCha because XChaCha has not been standardized. Also, xChaCha's improved collision
|
||||
resistance of nonces isn't necessary since every message has a new (key, nonce) pair.
|
||||
- HMAC-SHA256 is used instead of Poly1305 because polynomial MACs are much easier to forge.
|
||||
- SHA256 is used instead of SHA3 or BLAKE because it is already used in nostr. Also BLAKE's speed advantage
|
||||
is smaller in non-parallel environments.
|
||||
- A custom padding scheme is used instead of padmé because it provides better leakage reduction for small messages.
|
||||
- Base64 encoding is used instead of another encoding algorithm because it is widely available, and is already used in nostr.
|
||||
|
||||
### Encryption
|
||||
|
||||
1. Calculate a conversation key
|
||||
- Execute ECDH (scalar multiplication) of public key B by private key A
|
||||
Output `shared_x` must be unhashed, 32-byte encoded x coordinate of the shared point
|
||||
- Use HKDF-extract with sha256, `IKM=shared_x` and `salt=utf8_encode('nip44-v2')`
|
||||
- HKDF output will be a `conversation_key` between two users.
|
||||
- It is always the same, when key roles are swapped: `conv(a, B) == conv(b, A)`
|
||||
2. Generate a random 32-byte nonce
|
||||
- Always use [CSPRNG](https://en.wikipedia.org/wiki/Cryptographically_secure_pseudorandom_number_generator)
|
||||
- Don't generate a nonce from message content
|
||||
- Don't re-use the same nonce between messages: doing so would make them decryptable,
|
||||
but won't leak the long-term key
|
||||
3. Calculate message keys
|
||||
- The keys are generated from `conversation_key` and `nonce`. Validate that both are 32 bytes long
|
||||
- Use HKDF-expand, with sha256, `PRK=conversation_key`, `info=nonce` and `L=76`
|
||||
- Slice 76-byte HKDF output into: `chacha_key` (bytes 0..32), `chacha_nonce` (bytes 32..44), `hmac_key` (bytes 44..76)
|
||||
4. Add padding
|
||||
- Content must be encoded from UTF-8 into byte array
|
||||
- Validate plaintext length. Minimum is 1 byte, maximum is 65535 bytes
|
||||
- Padding format is: `[plaintext_length: u16][plaintext][zero_bytes]`
|
||||
- Padding algorithm is related to powers-of-two, with min padded msg size of 32 bytes
|
||||
- Plaintext length is encoded in big-endian as first 2 bytes of the padded blob
|
||||
5. Encrypt padded content
|
||||
- Use ChaCha20, with key and nonce from step 3
|
||||
6. Calculate MAC (message authentication code)
|
||||
- AAD (additional authenticated data) is used - instead of calculating MAC on ciphertext,
|
||||
it's calculated over a concatenation of `nonce` and `ciphertext`
|
||||
- Validate that AAD (nonce) is 32 bytes
|
||||
7. Base64-encode (with padding) params using `concat(version, nonce, ciphertext, mac)`
|
||||
|
||||
Encrypted payloads MUST be included in an event's payload, hashed, and signed as defined in NIP 01, using schnorr
|
||||
signature scheme over secp256k1.
|
||||
|
||||
### Decryption
|
||||
|
||||
Before decryption, the event's pubkey and signature MUST be validated as defined in NIP 01. The public key MUST be
|
||||
a valid non-zero secp256k1 curve point, and the signature must be valid secp256k1 schnorr signature. For exact
|
||||
validation rules, refer to BIP-340.
|
||||
|
||||
1. Check if first payload's character is `#`
|
||||
- `#` is an optional future-proof flag that means non-base64 encoding is used
|
||||
- The `#` is not present in base64 alphabet, but, instead of throwing `base64 is invalid`,
|
||||
implementations MUST indicate that the encryption version is not yet supported
|
||||
2. Decode base64
|
||||
- Base64 is decoded into `version, nonce, ciphertext, mac`
|
||||
- If the version is unknown, implementations must indicate that the encryption version is not supported
|
||||
- Validate length of base64 message to prevent DoS on base64 decoder: it can be in range from 132 to 87472 chars
|
||||
- Validate length of decoded message to verify output of the decoder: it can be in range from 99 to 65603 bytes
|
||||
3. Calculate conversation key
|
||||
- See step 1 of [encryption](#Encryption)
|
||||
4. Calculate message keys
|
||||
- See step 3 of [encryption](#Encryption)
|
||||
5. Calculate MAC (message authentication code) with AAD and compare
|
||||
- Stop and throw an error if MAC doesn't match the decoded one from step 2
|
||||
- Use constant-time comparison algorithm
|
||||
6. Decrypt ciphertext
|
||||
- Use ChaCha20 with key and nonce from step 3
|
||||
7. Remove padding
|
||||
- Read the first two BE bytes of plaintext that correspond to plaintext length
|
||||
- Verify that the length of sliced plaintext matches the value of the two BE bytes
|
||||
- Verify that calculated padding from step 3 of the [encryption](#Encryption) process matches the actual padding
|
||||
|
||||
### Details
|
||||
|
||||
- Cryptographic methods
|
||||
- `secure_random_bytes(length)` fetches randomness from CSPRNG.
|
||||
- `hkdf(IKM, salt, info, L)` represents HKDF [(RFC 5869)](https://datatracker.ietf.org/doc/html/rfc5869)
|
||||
with SHA256 hash function comprised of methods `hkdf_extract(IKM, salt)` and `hkdf_expand(OKM, info, L)`.
|
||||
- `chacha20(key, nonce, data)` is ChaCha20 [(RFC 8439)](https://datatracker.ietf.org/doc/html/rfc8439) with
|
||||
starting counter set to 0.
|
||||
- `hmac_sha256(key, message)` is HMAC [(RFC 2104)](https://datatracker.ietf.org/doc/html/rfc2104).
|
||||
- `secp256k1_ecdh(priv_a, pub_b)` is multiplication of point B by scalar a (`a ⋅ B`), defined in
|
||||
[BIP340](https://github.com/bitcoin/bips/blob/e918b50731397872ad2922a1b08a5a4cd1d6d546/bip-0340.mediawiki).
|
||||
The operation produces a shared point, and we encode the shared point's 32-byte x coordinate, using method
|
||||
`bytes(P)` from BIP340. Private and public keys must be validated as per BIP340: pubkey must be a valid,
|
||||
on-curve point, and private key must be a scalar in range `[1, secp256k1_order - 1]`.
|
||||
NIP44 doesn't do hashing of the output: keep this in mind, because some libraries hash it using sha256.
|
||||
As an example, in libsecp256k1, unhashed version is available in `secp256k1_ec_pubkey_tweak_mul`
|
||||
- Operators
|
||||
- `x[i:j]`, where `x` is a byte array and `i, j <= 0` returns a `(j - i)`-byte array with a copy of the
|
||||
`i`-th byte (inclusive) to the `j`-th byte (exclusive) of `x`.
|
||||
- Constants `c`:
|
||||
- `min_plaintext_size` is 1. 1 byte msg is padded to 32 bytes.
|
||||
- `max_plaintext_size` is 65535 (64kB - 1). It is padded to 65536 bytes.
|
||||
- Functions
|
||||
- `base64_encode(string)` and `base64_decode(bytes)` are Base64 ([RFC 4648](https://datatracker.ietf.org/doc/html/rfc4648), with padding)
|
||||
- `concat` refers to byte array concatenation
|
||||
- `is_equal_ct(a, b)` is constant-time equality check of 2 byte arrays
|
||||
- `utf8_encode(string)` and `utf8_decode(bytes)` transform string to byte array and back
|
||||
- `write_u8(number)` restricts number to values 0..255 and encodes into Big-Endian uint8 byte array
|
||||
- `write_u16_be(number)` restricts number to values 0..65535 and encodes into Big-Endian uint16 byte array
|
||||
- `zeros(length)` creates byte array of length `length >= 0`, filled with zeros
|
||||
- `floor(number)` and `log2(number)` are well-known mathematical methods
|
||||
|
||||
### Implementation pseudocode
|
||||
|
||||
The following is a collection of python-like pseudocode functions which implement the above primitives,
|
||||
intended to guide implementers. A collection of implementations in different languages is available at https://github.com/paulmillr/nip44.
|
||||
|
||||
```py
|
||||
# Calculates length of the padded byte array.
|
||||
def calc_padded_len(unpadded_len):
|
||||
next_power = 1 << (floor(log2(unpadded_len - 1))) + 1
|
||||
if next_power <= 256:
|
||||
chunk = 32
|
||||
else:
|
||||
chunk = next_power / 8
|
||||
if unpadded_len <= 32:
|
||||
return 32
|
||||
else:
|
||||
return chunk * (floor((len - 1) / chunk) + 1)
|
||||
|
||||
# Converts unpadded plaintext to padded bytearray
|
||||
def pad(plaintext):
|
||||
unpadded = utf8_encode(plaintext)
|
||||
unpadded_len = len(plaintext)
|
||||
if (unpadded_len < c.min_plaintext_size or
|
||||
unpadded_len > c.max_plaintext_size): raise Exception('invalid plaintext length')
|
||||
prefix = write_u16_be(unpadded_len)
|
||||
suffix = zeros(calc_padded_len(unpadded_len) - unpadded_len)
|
||||
return concat(prefix, unpadded, suffix)
|
||||
|
||||
# Converts padded bytearray to unpadded plaintext
|
||||
def unpad(padded):
|
||||
unpadded_len = read_uint16_be(padded[0:2])
|
||||
unpadded = padded[2:2+unpadded_len]
|
||||
if (unpadded_len == 0 or
|
||||
len(unpadded) != unpadded_len or
|
||||
len(padded) != 2 + calc_padded_len(unpadded_len)): raise Exception('invalid padding')
|
||||
return utf8_decode(unpadded)
|
||||
|
||||
# metadata: always 65b (version: 1b, nonce: 32b, max: 32b)
|
||||
# plaintext: 1b to 0xffff
|
||||
# padded plaintext: 32b to 0xffff
|
||||
# ciphertext: 32b+2 to 0xffff+2
|
||||
# raw payload: 99 (65+32+2) to 65603 (65+0xffff+2)
|
||||
# compressed payload (base64): 132b to 87472b
|
||||
def decode_payload(payload):
|
||||
plen = len(payload)
|
||||
if plen == 0 or payload[0] == '#': raise Exception('unknown version')
|
||||
if plen < 132 or plen > 87472: raise Exception('invalid payload size')
|
||||
data = base64_decode(payload)
|
||||
dlen = len(d)
|
||||
if dlen < 99 or dlen > 65603: raise Exception('invalid data size');
|
||||
vers = data[0]
|
||||
if vers != 2: raise Exception('unknown version ' + vers)
|
||||
nonce = data[1:33]
|
||||
ciphertext = data[33:dlen - 32]
|
||||
mac = data[dlen - 32:dlen]
|
||||
return (nonce, ciphertext, mac)
|
||||
|
||||
def hmac_aad(key, message, aad):
|
||||
if len(aad) != 32: raise Exception('AAD associated data must be 32 bytes');
|
||||
return hmac(sha256, key, concat(aad, message));
|
||||
|
||||
# Calculates long-term key between users A and B: `get_key(Apriv, Bpub) == get_key(Bpriv, Apub)`
|
||||
def get_conversation_key(private_key_a, public_key_b):
|
||||
shared_x = secp256k1_ecdh(private_key_a, public_key_b)
|
||||
return hkdf_extract(IKM=shared_x, salt=utf8_encode('nip44-v2'))
|
||||
|
||||
# Calculates unique per-message key
|
||||
def get_message_keys(conversation_key, nonce):
|
||||
if len(conversation_key) != 32: raise Exception('invalid conversation_key length')
|
||||
if len(nonce) != 32: raise Exception('invalid nonce length')
|
||||
keys = hkdf_expand(OKM=conversation_key, info=nonce, L=76)
|
||||
chacha_key = keys[0:32]
|
||||
chacha_nonce = keys[32:44]
|
||||
hmac_key = keys[44:76]
|
||||
return (chacha_key, chacha_nonce, hmac_key)
|
||||
|
||||
def encrypt(plaintext, conversation_key, nonce):
|
||||
(chacha_key, chacha_nonce, hmac_key) = get_message_keys(conversation_key, nonce)
|
||||
padded = pad(plaintext)
|
||||
ciphertext = chacha20(key=chacha_key, nonce=chacha_nonce, data=padded)
|
||||
mac = hmac_aad(key=hmac_key, message=ciphertext, aad=nonce)
|
||||
return base64_encode(concat(write_u8(2), nonce, ciphertext, mac))
|
||||
|
||||
def decrypt(payload, conversation_key):
|
||||
(nonce, ciphertext, mac) = decode_payload(payload)
|
||||
(chacha_key, chacha_nonce, hmac_key) = get_message_keys(conversation_key, nonce)
|
||||
calculated_mac = hmac_aad(key=hmac_key, message=ciphertext, aad=nonce)
|
||||
if not is_equal_ct(calculated_mac, mac): raise Exception('invalid MAC')
|
||||
padded_plaintext = chacha20(key=chacha_key, nonce=chacha_nonce, data=ciphertext)
|
||||
return unpad(padded_plaintext)
|
||||
|
||||
# Usage:
|
||||
# conversation_key = get_conversation_key(sender_privkey, recipient_pubkey)
|
||||
# nonce = secure_random_bytes(32)
|
||||
# payload = encrypt('hello world', conversation_key, nonce)
|
||||
# 'hello world' == decrypt(payload, conversation_key)
|
||||
```
|
||||
|
||||
### Audit
|
||||
|
||||
The v2 of the standard was audited by [Cure53](https://cure53.de) in December 2023.
|
||||
Check out [audit-2023.12.pdf](https://github.com/paulmillr/nip44/blob/ce63c2eaf345e9f7f93b48f829e6bdeb7e7d7964/audit-2023.12.pdf)
|
||||
and [auditor's website](https://cure53.de/audit-report_nip44-implementations.pdf).
|
||||
|
||||
### Tests and code
|
||||
|
||||
A collection of implementations in different languages is available at https://github.com/paulmillr/nip44.
|
||||
|
||||
We publish extensive test vectors. Instead of having it in the document directly, a sha256 checksum of vectors is provided:
|
||||
|
||||
269ed0f69e4c192512cc779e78c555090cebc7c785b609e338a62afc3ce25040 nip44.vectors.json
|
||||
|
||||
Example of a test vector from the file:
|
||||
|
||||
```json
|
||||
{
|
||||
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"sec2": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
|
||||
"nonce": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"plaintext": "a",
|
||||
"payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
|
||||
}
|
||||
```
|
||||
|
||||
The file also contains intermediate values. A quick guidance with regards to its usage:
|
||||
|
||||
- `valid.get_conversation_key`: calculate conversation_key from secret key sec1 and public key pub2
|
||||
- `valid.get_message_keys`: calculate chacha_key, chacha_nonce, hmac_key from conversation_key and nonce
|
||||
- `valid.calc_padded_len`: take unpadded length (first value), calculate padded length (second value)
|
||||
- `valid.encrypt_decrypt`: emulate real conversation. Calculate pub2 from sec2, verify conversation_key from (sec1, pub2), encrypt, verify payload, then calculate pub1 from sec1, verify conversation_key from (sec2, pub1), decrypt, verify plaintext.
|
||||
- `valid.encrypt_decrypt_long_msg`: same as previous step, but instead of a full plaintext and payload, their checksum is provided.
|
||||
- `invalid.encrypt_msg_lengths`
|
||||
- `invalid.get_conversation_key`: calculating conversation_key must throw an error
|
||||
- `invalid.decrypt`: decrypting message content must throw an error
|
||||
@@ -1,2 +1,137 @@
|
||||
Nostr_Login_Lite
|
||||
===========
|
||||
|
||||
## API
|
||||
|
||||
Complete configuration showing all available options:
|
||||
|
||||
```javascript
|
||||
await window.NOSTR_LOGIN_LITE.init({
|
||||
// Theme configuration
|
||||
theme: 'default', // 'default' | 'dark' | custom theme name
|
||||
|
||||
// 🔐 Authentication persistence configuration
|
||||
persistence: true, // Enable persistent authentication (default: true)
|
||||
isolateSession: false, // Use sessionStorage for per-tab isolation (default: false = localStorage)
|
||||
|
||||
// Relay configuration
|
||||
relays: ['wss://relay.damus.io', 'wss://nos.lol'],
|
||||
|
||||
// Authentication methods
|
||||
methods: {
|
||||
extension: true, // Browser extensions (Alby, nos2x, etc.)
|
||||
local: true, // Manual key entry & generation
|
||||
readonly: true, // Read-only mode (no signing)
|
||||
seedphrase: true,
|
||||
connect: true, // NIP-46 remote signers
|
||||
nsigner: true, // USB Hardware signer (n_signer via WebUSB)
|
||||
otp: false // OTP/DM authentication (not implemented yet)
|
||||
},
|
||||
|
||||
// Floating tab configuration
|
||||
floatingTab: {
|
||||
enabled: true, // Show/hide floating login tab
|
||||
hPosition: 0.95, // 0.0 = left edge, 1.0 = right edge
|
||||
vPosition: 0.1, // 0.0 = top edge, 1.0 = bottom edge
|
||||
offset: { x: 0, y: 0 }, // Fine-tune positioning (pixels)
|
||||
|
||||
appearance: {
|
||||
style: 'pill', // 'pill' | 'square' | 'circle'
|
||||
theme: 'auto', // 'auto' | 'light' | 'dark'
|
||||
icon: '[LOGIN]', // Text-based icon
|
||||
text: 'Sign In', // Button text
|
||||
iconOnly: false // Show icon only (no text)
|
||||
},
|
||||
|
||||
behavior: {
|
||||
hideWhenAuthenticated: false, // Keep visible after login
|
||||
showUserInfo: true, // Show user info when authenticated
|
||||
autoSlide: true, // Slide animation on hover
|
||||
persistent: false // Persist across page reloads
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Control Methods
|
||||
NOSTR_LOGIN_LITE.launch(); // Open login modal
|
||||
NOSTR_LOGIN_LITE.logout(); // Clear authentication state
|
||||
NOSTR_LOGIN_LITE.switchTheme('dark'); // Change theme
|
||||
NOSTR_LOGIN_LITE.showFloatingTab(); // Show floating tab
|
||||
NOSTR_LOGIN_LITE.hideFloatingTab(); // Hide floating tab
|
||||
NOSTR_LOGIN_LITE.updateFloatingTab(options); // Update floating tab options
|
||||
NOSTR_LOGIN_LITE.toggleFloatingTab(); // Toggle floating tab visibility
|
||||
|
||||
// Get Authentication State (Single Source of Truth)
|
||||
const authState = NOSTR_LOGIN_LITE.getAuthState();
|
||||
const isAuthenticated = !!authState;
|
||||
const userInfo = authState; // Contains { method, pubkey, etc. }
|
||||
```
|
||||
|
||||
**Authentication Persistence:**
|
||||
|
||||
Two-tier configuration system:
|
||||
|
||||
1. **`persistence: boolean`** - Master switch for authentication persistence
|
||||
- `true` (default): Save authentication state for automatic restore
|
||||
- `false`: No persistence - user must login fresh every time
|
||||
|
||||
2. **`isolateSession: boolean`** - Storage location when persistence is enabled
|
||||
- `false` (default): Use localStorage - shared across tabs/windows
|
||||
- `true`: Use sessionStorage - isolated per tab/window
|
||||
|
||||
**Use Cases for Session Isolation (`isolateSession: true`):**
|
||||
- Multi-tenant applications where different tabs need different users
|
||||
- Testing environments requiring separate authentication per tab
|
||||
- Privacy-focused applications that shouldn't share login state across tabs
|
||||
|
||||
## Embedded Modal API
|
||||
|
||||
Embed login interface directly into page element:
|
||||
|
||||
```javascript
|
||||
// Initialize library first
|
||||
await NOSTR_LOGIN_LITE.init({
|
||||
methods: {
|
||||
extension: true,
|
||||
local: true,
|
||||
readonly: true
|
||||
}
|
||||
});
|
||||
|
||||
// Embed into container
|
||||
const modal = NOSTR_LOGIN_LITE.embed('#login-container', {
|
||||
title: 'Login',
|
||||
showHeader: true,
|
||||
seamless: false // true = no borders/shadows, blends into page
|
||||
});
|
||||
```
|
||||
|
||||
Container can be CSS selector or DOM element. Modal renders inline without backdrop overlay.
|
||||
|
||||
## Logout API
|
||||
|
||||
To log out users and clear authentication state:
|
||||
|
||||
```javascript
|
||||
// Unified logout method - works for all authentication methods
|
||||
window.NOSTR_LOGIN_LITE.logout();
|
||||
```
|
||||
|
||||
This will:
|
||||
- Clear persistent authentication data from localStorage
|
||||
- Dispatch `nlLogout` event for custom cleanup
|
||||
- Reset the authentication state across all components
|
||||
|
||||
### Event Handling
|
||||
|
||||
Listen for logout events in your application:
|
||||
|
||||
```javascript
|
||||
window.addEventListener('nlLogout', () => {
|
||||
console.log('User logged out');
|
||||
// Clear your application's UI state
|
||||
// Redirect to login page, etc.
|
||||
});
|
||||
```
|
||||
|
||||
The logout system works consistently across all authentication methods (extension, local keys, NIP-46, etc.) and all UI components (floating tab, modal, embedded).
|
||||
|
||||
+6593
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
rsync -avz --chmod=644 --progress \
|
||||
build/{nostr-lite.js,nostr.bundle.js} \
|
||||
examples/{nsigner.html,feather_webusb_demo.html,cyd_webserial_demo.html} \
|
||||
ubuntu@laantungir.net:html/nostr-login-lite/
|
||||
@@ -1,50 +0,0 @@
|
||||
# NOSTR_LOGIN_LITE Examples
|
||||
|
||||
This directory contains examples and tests for NOSTR_LOGIN_LITE using the local bundle setup.
|
||||
|
||||
## Files
|
||||
|
||||
### 🔬 comprehensive-test.html
|
||||
**The main diagnostic and testing tool**
|
||||
- Comprehensive test suite with extensive debugging output
|
||||
- Tests all library functions, dependencies, crypto, storage, etc.
|
||||
- Results displayed on webpage for easy copying and debugging
|
||||
- Run this when you need to diagnose issues or verify functionality
|
||||
|
||||
### 📱 simple-demo.html
|
||||
Basic demonstration of NOSTR_LOGIN_LITE integration
|
||||
- Minimal setup example
|
||||
- Good starting point for new implementations
|
||||
|
||||
### 🎨 modal-login-demo.html
|
||||
Demonstrates modal-based login flow
|
||||
- Shows how to trigger and handle the login modal
|
||||
- Example of auth event handling
|
||||
|
||||
### 👤 login-and-profile.html
|
||||
Login and user profile demonstration
|
||||
- Shows authentication flow
|
||||
- Displays user profile information after login
|
||||
|
||||
### 🔗 nip46-bunker-demo.html
|
||||
NIP-46 remote signing demonstration
|
||||
- Shows how to connect to remote signers/bunkers
|
||||
- Advanced use case example
|
||||
|
||||
## Usage
|
||||
|
||||
1. Start a local web server (e.g., `python -m http.server 5501` or Live Server in VS Code)
|
||||
2. Navigate to any HTML file
|
||||
3. For comprehensive testing and debugging, use `comprehensive-test.html`
|
||||
|
||||
All examples use the local bundle setup with two files:
|
||||
1. `../lite/nostr.bundle.js` - Official nostr-tools bundle
|
||||
2. `../lite/nostr-lite.js` - NOSTR_LOGIN_LITE library with embedded NIP-46 extension
|
||||
|
||||
## Architecture Update (2025-09-13)
|
||||
|
||||
The library has been simplified from a three-file to a two-file architecture:
|
||||
- ✅ **Before:** `nostr.bundle.js` + `nip46-extension.js` + `nostr-lite.js`
|
||||
- ✅ **Now:** `nostr.bundle.js` + `nostr-lite.js` (with embedded NIP-46)
|
||||
|
||||
All functionality remains identical - NIP-46 remote signing, all auth methods, and full compatibility are preserved.
|
||||
@@ -0,0 +1,134 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Embedded NOSTR_LOGIN_LITE</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 40px;
|
||||
background: white;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 90vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#login-button {
|
||||
background: #0066cc;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
#login-button:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div id="login-button">Login</div>
|
||||
</div>
|
||||
|
||||
<script src="../lite/nostr.bundle.js"></script>
|
||||
<script src="../lite/nostr-lite.js"></script>
|
||||
|
||||
<script>
|
||||
let isAuthenticated = false;
|
||||
let currentUser = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await window.NOSTR_LOGIN_LITE.init({
|
||||
|
||||
methods: {
|
||||
extension: true,
|
||||
local: true,
|
||||
readonly: true,
|
||||
connect: true,
|
||||
remote: true,
|
||||
otp: true
|
||||
},
|
||||
floatingTab: {
|
||||
enabled: false
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for authentication events
|
||||
window.addEventListener('nlMethodSelected', handleAuthEvent);
|
||||
window.addEventListener('nlLogout', handleLogoutEvent);
|
||||
|
||||
// Check for existing authentication state
|
||||
checkAuthState();
|
||||
|
||||
// Initialize button
|
||||
updateButtonState();
|
||||
});
|
||||
|
||||
function handleAuthEvent(event) {
|
||||
const { pubkey, method } = event.detail;
|
||||
console.log(`Authenticated with ${method}, pubkey: ${pubkey}`);
|
||||
|
||||
isAuthenticated = true;
|
||||
currentUser = event.detail;
|
||||
updateButtonState();
|
||||
}
|
||||
|
||||
function handleLogoutEvent() {
|
||||
console.log('Logout event received');
|
||||
|
||||
isAuthenticated = false;
|
||||
currentUser = null;
|
||||
updateButtonState();
|
||||
}
|
||||
|
||||
function checkAuthState() {
|
||||
// Check if user is already authenticated (from persistent storage)
|
||||
try {
|
||||
// Try to get public key - this will work if already authenticated
|
||||
window.nostr.getPublicKey().then(pubkey => {
|
||||
console.log('Found existing authentication, pubkey:', pubkey);
|
||||
isAuthenticated = true;
|
||||
currentUser = { pubkey, method: 'persistent' };
|
||||
updateButtonState();
|
||||
}).catch(error => {
|
||||
console.log('No existing authentication found:', error.message);
|
||||
// User is not authenticated, button stays in login state
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('No existing authentication found');
|
||||
// User is not authenticated, button stays in login state
|
||||
}
|
||||
}
|
||||
|
||||
function updateButtonState() {
|
||||
const button = document.getElementById('login-button');
|
||||
|
||||
if (isAuthenticated) {
|
||||
button.textContent = 'Logout';
|
||||
button.onclick = () => window.NOSTR_LOGIN_LITE.logout();
|
||||
button.style.background = '#dc3545'; // Red for logout
|
||||
} else {
|
||||
button.textContent = 'Login';
|
||||
button.onclick = () => window.NOSTR_LOGIN_LITE.launch('login');
|
||||
button.style.background = '#0066cc'; // Blue for login
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,735 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>n_signer CYD Web Serial Demo</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0f14;
|
||||
--panel: #121821;
|
||||
--panel-2: #182231;
|
||||
--text: #e6edf3;
|
||||
--muted: #9fb0c3;
|
||||
--accent: #58a6ff;
|
||||
--good: #3fb950;
|
||||
--bad: #f85149;
|
||||
--border: #263448;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 980px;
|
||||
margin: 20px auto;
|
||||
padding: 0 14px 24px;
|
||||
}
|
||||
h1 { margin: 0 0 8px; font-size: 1.45rem; }
|
||||
p.note { margin: 0 0 14px; color: var(--muted); }
|
||||
|
||||
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.card {
|
||||
background: linear-gradient(180deg, var(--panel), var(--panel-2));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.86rem;
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
input, textarea, button {
|
||||
font: inherit;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
background: #0c131d;
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
textarea { min-height: 84px; resize: vertical; }
|
||||
input[type="number"] { max-width: 130px; }
|
||||
|
||||
button {
|
||||
background: #1f6feb;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
}
|
||||
button[disabled] {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.secondary { background: #334155; }
|
||||
.danger { background: #7f1d1d; }
|
||||
|
||||
.status {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #2a3648;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.status.ok { color: var(--good); border-color: #2f5a3a; }
|
||||
.status.err { color: var(--bad); border-color: #6a3131; }
|
||||
|
||||
pre {
|
||||
margin: 8px 0 0;
|
||||
background: #0a1018;
|
||||
border: 1px solid #1b2636;
|
||||
color: #d7e2ee;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
max-height: 220px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
|
||||
.info-box {
|
||||
background: #0d1f35;
|
||||
border: 1px solid #1e3a5f;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 0.84rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.info-box strong { color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>n_signer CYD Web Serial Demo</h1>
|
||||
<p class="note">Connect to the CYD (ESP32-2432S028) board over Web Serial (CH340/CP2102 USB-UART bridge), choose a key index, fetch a pubkey, sign a kind 1 event, and test NIP-04 / NIP-44 encrypt + decrypt RPCs.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>Requirements:</strong> Chrome 89+, Edge 89+, or Brave. Web Serial is not available in Firefox or Safari.<br>
|
||||
<strong>Linux users:</strong> Add yourself to the <code>dialout</code> group (<code>sudo usermod -aG dialout $USER</code>) and replug the device.<br>
|
||||
<strong>Windows users:</strong> Install the WCH-IC CH340 driver if the port does not appear.<br>
|
||||
<strong>Note:</strong> Close any serial monitor (Arduino IDE, idf.py monitor) before connecting — only one app can hold the port at a time.
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<button id="connectBtn">Connect CYD (Serial)</button>
|
||||
<button id="disconnectBtn" class="danger" disabled>Disconnect</button>
|
||||
<span id="connStatus" class="status">Disconnected</span>
|
||||
</div>
|
||||
<pre id="log" class="mono"></pre>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<section class="card">
|
||||
<h2>Public Key</h2>
|
||||
<label for="keyIndex">Key index (nostr_index)</label>
|
||||
<input id="keyIndex" type="text" inputmode="numeric" pattern="[0-9]*" value="0" />
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="pubkeyBtn" disabled>Get Public Key</button>
|
||||
</div>
|
||||
<pre id="pubkeyOut" class="mono"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Sign Kind 1 Event</h2>
|
||||
<label for="kind1Content">Content</label>
|
||||
<textarea id="kind1Content">hello from cyd webserial demo</textarea>
|
||||
<label for="kind1Tags">Tags JSON (array)</label>
|
||||
<input id="kind1Tags" value="[]" />
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="signKind1Btn" disabled>Create + Sign kind 1</button>
|
||||
</div>
|
||||
<pre id="signOut" class="mono"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>NIP-04 Encrypt</h2>
|
||||
<label for="nip04Peer">Peer pubkey (hex, 32-byte x-only)</label>
|
||||
<input id="nip04Peer" placeholder="e.g. 64 hex chars" />
|
||||
<label for="nip04Msg">Plaintext</label>
|
||||
<textarea id="nip04Msg">hello via nip04</textarea>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="nip04EncBtn" disabled>Encrypt (nip04_encrypt)</button>
|
||||
</div>
|
||||
<pre id="nip04Out" class="mono"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>NIP-04 Decrypt</h2>
|
||||
<label for="nip04DecPeer">Peer pubkey (hex, 32-byte x-only)</label>
|
||||
<input id="nip04DecPeer" placeholder="e.g. 64 hex chars" />
|
||||
<label for="nip04Cipher">Ciphertext</label>
|
||||
<textarea id="nip04Cipher" placeholder="ciphertext?iv=..."></textarea>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="nip04DecBtn" disabled>Decrypt (nip04_decrypt)</button>
|
||||
</div>
|
||||
<pre id="nip04DecOut" class="mono"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>NIP-44 Encrypt</h2>
|
||||
<label for="nip44Peer">Peer pubkey (hex, 32-byte x-only)</label>
|
||||
<input id="nip44Peer" placeholder="e.g. 64 hex chars" />
|
||||
<label for="nip44Msg">Plaintext</label>
|
||||
<textarea id="nip44Msg">hello via nip44</textarea>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="nip44EncBtn" disabled>Encrypt (nip44_encrypt)</button>
|
||||
</div>
|
||||
<pre id="nip44Out" class="mono"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>NIP-44 Decrypt</h2>
|
||||
<label for="nip44DecPeer">Peer pubkey (hex, 32-byte x-only)</label>
|
||||
<input id="nip44DecPeer" placeholder="e.g. 64 hex chars" />
|
||||
<label for="nip44Cipher">Ciphertext</label>
|
||||
<textarea id="nip44Cipher" placeholder="base64 payload"></textarea>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="nip44DecBtn" disabled>Decrypt (nip44_decrypt)</button>
|
||||
</div>
|
||||
<pre id="nip44DecOut" class="mono"></pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { schnorr } from "https://esm.sh/@noble/curves@1.5.0/secp256k1?bundle";
|
||||
|
||||
// ── DOM refs ──────────────────────────────────────────────────────────────
|
||||
const logEl = document.getElementById("log");
|
||||
const connStatusEl = document.getElementById("connStatus");
|
||||
const connectBtn = document.getElementById("connectBtn");
|
||||
const disconnectBtn = document.getElementById("disconnectBtn");
|
||||
const pubkeyBtn = document.getElementById("pubkeyBtn");
|
||||
const signKind1Btn = document.getElementById("signKind1Btn");
|
||||
const nip04EncBtn = document.getElementById("nip04EncBtn");
|
||||
const nip04DecBtn = document.getElementById("nip04DecBtn");
|
||||
const nip44EncBtn = document.getElementById("nip44EncBtn");
|
||||
const nip44DecBtn = document.getElementById("nip44DecBtn");
|
||||
|
||||
const keyIndexEl = document.getElementById("keyIndex");
|
||||
const pubkeyOutEl = document.getElementById("pubkeyOut");
|
||||
const kind1ContentEl = document.getElementById("kind1Content");
|
||||
const kind1TagsEl = document.getElementById("kind1Tags");
|
||||
const signOutEl = document.getElementById("signOut");
|
||||
const nip04PeerEl = document.getElementById("nip04Peer");
|
||||
const nip04MsgEl = document.getElementById("nip04Msg");
|
||||
const nip04OutEl = document.getElementById("nip04Out");
|
||||
const nip04DecPeerEl = document.getElementById("nip04DecPeer");
|
||||
const nip04CipherEl = document.getElementById("nip04Cipher");
|
||||
const nip04DecOutEl = document.getElementById("nip04DecOut");
|
||||
const nip44PeerEl = document.getElementById("nip44Peer");
|
||||
const nip44MsgEl = document.getElementById("nip44Msg");
|
||||
const nip44OutEl = document.getElementById("nip44Out");
|
||||
const nip44DecPeerEl = document.getElementById("nip44DecPeer");
|
||||
const nip44CipherEl = document.getElementById("nip44Cipher");
|
||||
const nip44DecOutEl = document.getElementById("nip44DecOut");
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
// Keep chooser unfiltered so any serial-capable device can be selected.
|
||||
const SERIAL_FILTERS = [];
|
||||
|
||||
let port = null; // SerialPort
|
||||
let writer = null; // WritableStreamDefaultWriter
|
||||
let reader = null; // ReadableStreamDefaultReader
|
||||
let ownPubkey = "";
|
||||
let rpcCounter = 0;
|
||||
|
||||
// Pending RPC promises keyed by request id
|
||||
const pending = new Map(); // id -> { resolve, reject, timer }
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function log(...args) {
|
||||
logEl.textContent += args.join(" ") + "\n";
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
function setStatus(text, mode = "") {
|
||||
connStatusEl.textContent = text;
|
||||
connStatusEl.className = `status ${mode}`.trim();
|
||||
}
|
||||
|
||||
function setConnected(connected) {
|
||||
connectBtn.disabled = connected;
|
||||
disconnectBtn.disabled = !connected;
|
||||
pubkeyBtn.disabled = !connected;
|
||||
signKind1Btn.disabled = !connected;
|
||||
nip04EncBtn.disabled = !connected;
|
||||
nip04DecBtn.disabled = !connected;
|
||||
nip44EncBtn.disabled = !connected;
|
||||
nip44DecBtn.disabled = !connected;
|
||||
}
|
||||
|
||||
function hex(bytes) {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function utf8(s) {
|
||||
return new TextEncoder().encode(s);
|
||||
}
|
||||
|
||||
function be32(n) {
|
||||
return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
||||
}
|
||||
|
||||
async function sha256Hex(dataBytes) {
|
||||
const h = await crypto.subtle.digest("SHA-256", dataBytes);
|
||||
return hex(new Uint8Array(h));
|
||||
}
|
||||
|
||||
function getIndexOptions() {
|
||||
const raw = Number.parseInt(String(keyIndexEl.value ?? "0"), 10);
|
||||
const index = Number.isFinite(raw) && raw >= 0 ? raw : 0;
|
||||
return { nostr_index: index };
|
||||
}
|
||||
|
||||
function pretty(value) {
|
||||
try { return JSON.stringify(value, null, 2); }
|
||||
catch { return String(value); }
|
||||
}
|
||||
|
||||
function requirePeerHex(peer) {
|
||||
const v = String(peer || "").trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/.test(v)) {
|
||||
throw new Error("Peer pubkey must be exactly 64 hex chars (x-only pubkey)");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function requireStringResult(resp, what) {
|
||||
if (resp && typeof resp.result === "string") return resp.result;
|
||||
throw new Error(`${what} failed: ${pretty(resp)}`);
|
||||
}
|
||||
|
||||
// ── Auth envelope ─────────────────────────────────────────────────────────
|
||||
async function buildAuth(method, params) {
|
||||
// Demo caller key — fixed, non-secret (demo only).
|
||||
const callerPriv = Uint8Array.from({ length: 32 }, (_, i) => i + 1);
|
||||
const callerPubX = hex(schnorr.getPublicKey(callerPriv));
|
||||
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
const paramsJson = JSON.stringify(params);
|
||||
const bodyHash = await sha256Hex(utf8(paramsJson));
|
||||
const tags = [
|
||||
["nsigner_rpc", String(++rpcCounter)],
|
||||
["nsigner_method", method],
|
||||
["nsigner_body_hash", bodyHash],
|
||||
];
|
||||
|
||||
const content = "cyd-webserial-demo";
|
||||
const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
|
||||
const id = await sha256Hex(utf8(ser));
|
||||
const sigBytes = await schnorr.sign(id, callerPriv, new Uint8Array(32));
|
||||
const sigHex = typeof sigBytes === "string" ? sigBytes : hex(sigBytes);
|
||||
|
||||
return { id, pubkey: callerPubX, created_at: createdAt, kind: 27235, tags, content, sig: sigHex };
|
||||
}
|
||||
|
||||
// ── Frame writer ──────────────────────────────────────────────────────────
|
||||
async function writeFrame(reqObj) {
|
||||
const body = utf8(JSON.stringify(reqObj));
|
||||
const frame = new Uint8Array(4 + body.length);
|
||||
frame.set(be32(body.length), 0);
|
||||
frame.set(body, 4);
|
||||
|
||||
// Acquire writer, write, release immediately so other callers can use it.
|
||||
const w = port.writable.getWriter();
|
||||
try {
|
||||
await w.write(frame);
|
||||
} finally {
|
||||
w.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Background read loop ──────────────────────────────────────────────────
|
||||
// Holds the reader for the lifetime of the connection.
|
||||
// Parses length-prefixed frames and resolves pending RPC promises.
|
||||
async function readLoop() {
|
||||
let ring = new Uint8Array(0);
|
||||
|
||||
try {
|
||||
reader = port.readable.getReader();
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value || value.length === 0) continue;
|
||||
|
||||
// Append chunk to ring buffer.
|
||||
const next = new Uint8Array(ring.length + value.length);
|
||||
next.set(ring, 0);
|
||||
next.set(value, ring.length);
|
||||
ring = next;
|
||||
|
||||
// Parse as many complete frames as possible.
|
||||
while (ring.length >= 4) {
|
||||
const n = (ring[0] << 24) | (ring[1] << 16) | (ring[2] << 8) | ring[3];
|
||||
|
||||
// Invalid length header — slide one byte (boot-log recovery).
|
||||
if (n <= 0 || n > 1_000_000) {
|
||||
ring = ring.slice(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not enough bytes yet for the full payload.
|
||||
if (ring.length < 4 + n) break;
|
||||
|
||||
const payload = ring.slice(4, 4 + n);
|
||||
ring = ring.slice(4 + n);
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = JSON.parse(new TextDecoder().decode(payload));
|
||||
} catch (e) {
|
||||
log("⚠ Frame parse error:", String(e));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the matching pending RPC.
|
||||
if (resp && resp.id && pending.has(resp.id)) {
|
||||
const { resolve, timer } = pending.get(resp.id);
|
||||
pending.delete(resp.id);
|
||||
clearTimeout(timer);
|
||||
resolve(resp);
|
||||
} else {
|
||||
log("← unsolicited frame:", JSON.stringify(resp));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// reader.cancel() throws a non-error on clean close; ignore it.
|
||||
if (err && err.name !== "AbortError") {
|
||||
log("Read loop error:", String(err));
|
||||
}
|
||||
} finally {
|
||||
try { reader.releaseLock(); } catch (_) {}
|
||||
reader = null;
|
||||
handleDisconnect("Device disconnected");
|
||||
}
|
||||
}
|
||||
|
||||
// ── RPC call ──────────────────────────────────────────────────────────────
|
||||
function sendRpc(reqObj) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = reqObj.id;
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error("Timed out waiting for n_signer response"));
|
||||
}, 30000);
|
||||
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
writeFrame(reqObj).catch(err => {
|
||||
pending.delete(id);
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function rpcCall(method, params) {
|
||||
const id = `cyd-${Date.now()}-${++rpcCounter}`;
|
||||
const auth = await buildAuth(method, params);
|
||||
const req = { jsonrpc: "2.0", id, method, params, auth };
|
||||
log("→", method, JSON.stringify(params));
|
||||
const resp = await sendRpc(req);
|
||||
log("←", method, JSON.stringify(resp));
|
||||
return resp;
|
||||
}
|
||||
|
||||
// ── Connect / disconnect ──────────────────────────────────────────────────
|
||||
async function applySignalStrategies(serialPort) {
|
||||
const strategies = [
|
||||
{ dataTerminalReady: false, requestToSend: false, label: "dtr=0 rts=0" },
|
||||
{ dataTerminalReady: true, requestToSend: true, label: "dtr=1 rts=1" }
|
||||
];
|
||||
|
||||
if (!serialPort?.setSignals) return;
|
||||
|
||||
for (const s of strategies) {
|
||||
try {
|
||||
await serialPort.setSignals({
|
||||
dataTerminalReady: !!s.dataTerminalReady,
|
||||
requestToSend: !!s.requestToSend
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 120));
|
||||
} catch (e) {
|
||||
log("⚠ setSignals strategy failed:", s.label, String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function findReenumeratedPort(matchInfo = {}) {
|
||||
try {
|
||||
const ports = await navigator.serial.getPorts();
|
||||
if (!ports || !ports.length) return null;
|
||||
const vid = matchInfo?.usbVendorId;
|
||||
const pid = matchInfo?.usbProductId;
|
||||
return ports.find((p) => {
|
||||
const i = p.getInfo?.() || {};
|
||||
if (vid != null && i.usbVendorId !== vid) return false;
|
||||
if (pid != null && i.usbProductId !== pid) return false;
|
||||
return true;
|
||||
}) || ports[0] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (!("serial" in navigator)) {
|
||||
throw new Error("Web Serial API not available — use Chrome 89+, Edge 89+, or Brave");
|
||||
}
|
||||
|
||||
const initiallySelected = SERIAL_FILTERS.length
|
||||
? await navigator.serial.requestPort({ filters: SERIAL_FILTERS })
|
||||
: await navigator.serial.requestPort();
|
||||
const selectedInfo = initiallySelected.getInfo?.() || {};
|
||||
let lastError = null;
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
let droppedDuringOpen = false;
|
||||
|
||||
try {
|
||||
port = attempt === 0 ? initiallySelected : (await findReenumeratedPort(selectedInfo));
|
||||
if (!port) throw new Error("Serial port not available after re-enumeration");
|
||||
|
||||
const onPortDisconnect = () => {
|
||||
droppedDuringOpen = true;
|
||||
handleDisconnect("Port disconnect event");
|
||||
};
|
||||
|
||||
port.addEventListener("disconnect", onPortDisconnect);
|
||||
|
||||
await port.open({
|
||||
baudRate: 115200,
|
||||
dataBits: 8,
|
||||
parity: "none",
|
||||
stopBits: 1,
|
||||
flowControl: "none"
|
||||
});
|
||||
|
||||
await applySignalStrategies(port);
|
||||
|
||||
// Stabilize after open/signals: some CYD revisions briefly reset and re-enumerate.
|
||||
await new Promise(r => setTimeout(r, 1200));
|
||||
|
||||
if (droppedDuringOpen || !port?.readable || !port?.writable) {
|
||||
throw new Error("Serial port dropped during open stabilization");
|
||||
}
|
||||
|
||||
// Start background read loop (does not block).
|
||||
readLoop();
|
||||
|
||||
const info = port.getInfo();
|
||||
const vid = info?.usbVendorId != null ? `0x${info.usbVendorId.toString(16).padStart(4,"0")}` : "?";
|
||||
const pid = info?.usbProductId != null ? `0x${info.usbProductId.toString(16).padStart(4,"0")}` : "?";
|
||||
|
||||
setConnected(true);
|
||||
setStatus(`Connected (VID:${vid} PID:${pid})`, "ok");
|
||||
log(`Connected. VID:${vid} PID:${pid}`);
|
||||
|
||||
try {
|
||||
await fetchOwnPubkeyAndFillPeers();
|
||||
log("Default peer pubkeys set to selected signer pubkey");
|
||||
} catch (e) {
|
||||
log("Auto pubkey fetch failed:", String(e));
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
try { if (port) await port.close(); } catch (_) {}
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to open CYD serial port: ${String(lastError?.message || lastError)}`);
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
// Cancel the reader to break out of readLoop.
|
||||
if (reader) {
|
||||
try { await reader.cancel(); } catch (_) {}
|
||||
}
|
||||
|
||||
// Cancel all pending RPCs.
|
||||
for (const [id, { reject, timer }] of pending) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Connection closed"));
|
||||
}
|
||||
pending.clear();
|
||||
|
||||
try {
|
||||
if (port) await port.close();
|
||||
} catch (_) {}
|
||||
|
||||
port = null;
|
||||
setConnected(false);
|
||||
setStatus("Disconnected");
|
||||
log("Disconnected.");
|
||||
}
|
||||
|
||||
function handleDisconnect(reason) {
|
||||
if (!port) return; // already cleaned up
|
||||
log("⚠", reason);
|
||||
|
||||
// Reject all pending RPCs.
|
||||
for (const [id, { reject, timer }] of pending) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Device disconnected"));
|
||||
}
|
||||
pending.clear();
|
||||
|
||||
port = null;
|
||||
setConnected(false);
|
||||
setStatus("Disconnected — device reset or unplugged", "err");
|
||||
}
|
||||
|
||||
// ── Pubkey helper ─────────────────────────────────────────────────────────
|
||||
async function fetchOwnPubkeyAndFillPeers() {
|
||||
const params = [getIndexOptions()];
|
||||
const resp = await rpcCall("nostr_get_public_key", params);
|
||||
if (resp && typeof resp.result === "string") {
|
||||
ownPubkey = resp.result.trim().toLowerCase();
|
||||
pubkeyOutEl.textContent = pretty(resp);
|
||||
if (/^[0-9a-f]{64}$/.test(ownPubkey)) {
|
||||
nip04PeerEl.value = ownPubkey;
|
||||
nip04DecPeerEl.value = ownPubkey;
|
||||
nip44PeerEl.value = ownPubkey;
|
||||
nip44DecPeerEl.value = ownPubkey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Button handlers ───────────────────────────────────────────────────────
|
||||
connectBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await connect();
|
||||
} catch (e) {
|
||||
setStatus("Connect failed", "err");
|
||||
log("Connect failed:", String(e));
|
||||
}
|
||||
});
|
||||
|
||||
disconnectBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await disconnect();
|
||||
} catch (e) {
|
||||
log("Disconnect error:", String(e));
|
||||
}
|
||||
});
|
||||
|
||||
pubkeyBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await fetchOwnPubkeyAndFillPeers();
|
||||
} catch (e) {
|
||||
pubkeyOutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
signKind1Btn.addEventListener("click", async () => {
|
||||
try {
|
||||
let tags = [];
|
||||
try {
|
||||
tags = JSON.parse(kind1TagsEl.value || "[]");
|
||||
if (!Array.isArray(tags)) throw new Error("tags must be an array");
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid tags JSON: ${String(e)}`);
|
||||
}
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: String(kind1ContentEl.value || ""),
|
||||
};
|
||||
|
||||
const params = [unsignedEvent, getIndexOptions()];
|
||||
const resp = await rpcCall("nostr_sign_event", params);
|
||||
signOutEl.textContent = pretty(resp?.result ?? resp);
|
||||
} catch (e) {
|
||||
signOutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
nip04EncBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const peer = requirePeerHex(nip04PeerEl.value);
|
||||
const msg = String(nip04MsgEl.value || "");
|
||||
const params = [peer, msg, getIndexOptions()];
|
||||
const resp = await rpcCall("nostr_nip04_encrypt", params);
|
||||
nip04OutEl.textContent = pretty(resp?.result ?? resp);
|
||||
if (resp && typeof resp.result === "string") {
|
||||
nip04DecPeerEl.value = peer;
|
||||
nip04CipherEl.value = resp.result;
|
||||
}
|
||||
} catch (e) {
|
||||
nip04OutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
nip04DecBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const peer = requirePeerHex(nip04DecPeerEl.value);
|
||||
const ciphertext = String(nip04CipherEl.value || "");
|
||||
const params = [peer, ciphertext, getIndexOptions()];
|
||||
const resp = await rpcCall("nostr_nip04_decrypt", params);
|
||||
nip04DecOutEl.textContent = requireStringResult(resp, "NIP-04 decrypt");
|
||||
} catch (e) {
|
||||
nip04DecOutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
nip44EncBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const peer = requirePeerHex(nip44PeerEl.value);
|
||||
const msg = String(nip44MsgEl.value || "");
|
||||
const params = [peer, msg, getIndexOptions()];
|
||||
const resp = await rpcCall("nostr_nip44_encrypt", params);
|
||||
nip44OutEl.textContent = pretty(resp?.result ?? resp);
|
||||
if (resp && typeof resp.result === "string") {
|
||||
nip44DecPeerEl.value = peer;
|
||||
nip44CipherEl.value = resp.result;
|
||||
}
|
||||
} catch (e) {
|
||||
nip44OutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
nip44DecBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const peer = requirePeerHex(nip44DecPeerEl.value);
|
||||
const ciphertext = String(nip44CipherEl.value || "");
|
||||
const params = [peer, ciphertext, getIndexOptions()];
|
||||
const resp = await rpcCall("nostr_nip44_decrypt", params);
|
||||
nip44DecOutEl.textContent = requireStringResult(resp, "NIP-44 decrypt");
|
||||
} catch (e) {
|
||||
nip44DecOutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Embedded NOSTR_LOGIN_LITE</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 40px;
|
||||
background: white;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#login-container {
|
||||
/* No styling - let embedded modal blend seamlessly */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div id="login-container"></div>
|
||||
</div>
|
||||
|
||||
<script src="../lite/nostr.bundle.js"></script>
|
||||
<script src="../lite/nostr-lite.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await window.NOSTR_LOGIN_LITE.init({
|
||||
theme:'default',
|
||||
methods: {
|
||||
extension: true,
|
||||
local: true,
|
||||
seedphrase: true,
|
||||
readonly: true,
|
||||
connect: true,
|
||||
remote: true,
|
||||
otp: true
|
||||
}
|
||||
});
|
||||
|
||||
window.NOSTR_LOGIN_LITE.embed('#login-container', {
|
||||
seamless: true
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,527 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>n_signer Feather WebUSB Demo</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0f14;
|
||||
--panel: #121821;
|
||||
--panel-2: #182231;
|
||||
--text: #e6edf3;
|
||||
--muted: #9fb0c3;
|
||||
--accent: #58a6ff;
|
||||
--good: #3fb950;
|
||||
--bad: #f85149;
|
||||
--border: #263448;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 980px;
|
||||
margin: 20px auto;
|
||||
padding: 0 14px 24px;
|
||||
}
|
||||
h1 { margin: 0 0 8px; font-size: 1.45rem; }
|
||||
p.note { margin: 0 0 14px; color: var(--muted); }
|
||||
|
||||
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.card {
|
||||
background: linear-gradient(180deg, var(--panel), var(--panel-2));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.86rem;
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
input, textarea, button {
|
||||
font: inherit;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
background: #0c131d;
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
textarea { min-height: 84px; resize: vertical; }
|
||||
input[type="number"] { max-width: 130px; }
|
||||
|
||||
button {
|
||||
background: #1f6feb;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
}
|
||||
button[disabled] {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.secondary { background: #334155; }
|
||||
|
||||
.status {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #2a3648;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.status.ok { color: var(--good); border-color: #2f5a3a; }
|
||||
.status.err { color: var(--bad); border-color: #6a3131; }
|
||||
|
||||
pre {
|
||||
margin: 8px 0 0;
|
||||
background: #0a1018;
|
||||
border: 1px solid #1b2636;
|
||||
color: #d7e2ee;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
max-height: 220px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>n_signer Feather WebUSB Demo</h1>
|
||||
<p class="note">Connect to the board, choose a key index, fetch a pubkey, sign a kind 1 event, and test NIP-04 / NIP-44 encrypt + decrypt RPCs.</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<button id="connectBtn">Connect WebUSB</button>
|
||||
<span id="connStatus" class="status">Disconnected</span>
|
||||
</div>
|
||||
<pre id="log" class="mono"></pre>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<section class="card">
|
||||
<h2>Public Key</h2>
|
||||
<label for="keyIndex">Key index (nostr_index)</label>
|
||||
<input id="keyIndex" type="text" inputmode="numeric" pattern="[0-9]*" value="0" />
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="pubkeyBtn" disabled>Get Public Key</button>
|
||||
</div>
|
||||
<pre id="pubkeyOut" class="mono"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Sign Kind 1 Event</h2>
|
||||
<label for="kind1Content">Content</label>
|
||||
<textarea id="kind1Content">hello from feather webusb demo</textarea>
|
||||
<label for="kind1Tags">Tags JSON (array)</label>
|
||||
<input id="kind1Tags" value="[]" />
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="signKind1Btn" disabled>Create + Sign kind 1</button>
|
||||
</div>
|
||||
<pre id="signOut" class="mono"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>NIP-04 Encrypt</h2>
|
||||
<label for="nip04Peer">Peer pubkey (hex, 32-byte x-only)</label>
|
||||
<input id="nip04Peer" placeholder="e.g. 64 hex chars" />
|
||||
<label for="nip04Msg">Plaintext</label>
|
||||
<textarea id="nip04Msg">hello via nip04</textarea>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="nip04EncBtn" disabled>Encrypt (nip04_encrypt)</button>
|
||||
</div>
|
||||
<pre id="nip04Out" class="mono"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>NIP-04 Decrypt</h2>
|
||||
<label for="nip04DecPeer">Peer pubkey (hex, 32-byte x-only)</label>
|
||||
<input id="nip04DecPeer" placeholder="e.g. 64 hex chars" />
|
||||
<label for="nip04Cipher">Ciphertext</label>
|
||||
<textarea id="nip04Cipher" placeholder="ciphertext?iv=..."></textarea>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="nip04DecBtn" disabled>Decrypt (nip04_decrypt)</button>
|
||||
</div>
|
||||
<pre id="nip04DecOut" class="mono"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>NIP-44 Encrypt</h2>
|
||||
<label for="nip44Peer">Peer pubkey (hex, 32-byte x-only)</label>
|
||||
<input id="nip44Peer" placeholder="e.g. 64 hex chars" />
|
||||
<label for="nip44Msg">Plaintext</label>
|
||||
<textarea id="nip44Msg">hello via nip44</textarea>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="nip44EncBtn" disabled>Encrypt (nip44_encrypt)</button>
|
||||
</div>
|
||||
<pre id="nip44Out" class="mono"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>NIP-44 Decrypt</h2>
|
||||
<label for="nip44DecPeer">Peer pubkey (hex, 32-byte x-only)</label>
|
||||
<input id="nip44DecPeer" placeholder="e.g. 64 hex chars" />
|
||||
<label for="nip44Cipher">Ciphertext</label>
|
||||
<textarea id="nip44Cipher" placeholder="base64 payload"></textarea>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="nip44DecBtn" disabled>Decrypt (nip44_decrypt)</button>
|
||||
</div>
|
||||
<pre id="nip44DecOut" class="mono"></pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { schnorr } from "https://esm.sh/@noble/curves@1.5.0/secp256k1?bundle";
|
||||
|
||||
const logEl = document.getElementById("log");
|
||||
const connStatusEl = document.getElementById("connStatus");
|
||||
|
||||
const connectBtn = document.getElementById("connectBtn");
|
||||
const pubkeyBtn = document.getElementById("pubkeyBtn");
|
||||
const signKind1Btn = document.getElementById("signKind1Btn");
|
||||
const nip04EncBtn = document.getElementById("nip04EncBtn");
|
||||
const nip04DecBtn = document.getElementById("nip04DecBtn");
|
||||
const nip44EncBtn = document.getElementById("nip44EncBtn");
|
||||
const nip44DecBtn = document.getElementById("nip44DecBtn");
|
||||
|
||||
const keyIndexEl = document.getElementById("keyIndex");
|
||||
const pubkeyOutEl = document.getElementById("pubkeyOut");
|
||||
|
||||
const kind1ContentEl = document.getElementById("kind1Content");
|
||||
const kind1TagsEl = document.getElementById("kind1Tags");
|
||||
const signOutEl = document.getElementById("signOut");
|
||||
|
||||
const nip04PeerEl = document.getElementById("nip04Peer");
|
||||
const nip04MsgEl = document.getElementById("nip04Msg");
|
||||
const nip04OutEl = document.getElementById("nip04Out");
|
||||
const nip04DecPeerEl = document.getElementById("nip04DecPeer");
|
||||
const nip04CipherEl = document.getElementById("nip04Cipher");
|
||||
const nip04DecOutEl = document.getElementById("nip04DecOut");
|
||||
|
||||
const nip44PeerEl = document.getElementById("nip44Peer");
|
||||
const nip44MsgEl = document.getElementById("nip44Msg");
|
||||
const nip44OutEl = document.getElementById("nip44Out");
|
||||
const nip44DecPeerEl = document.getElementById("nip44DecPeer");
|
||||
const nip44CipherEl = document.getElementById("nip44Cipher");
|
||||
const nip44DecOutEl = document.getElementById("nip44DecOut");
|
||||
|
||||
let dev = null;
|
||||
let iface = null;
|
||||
let ownPubkey = "";
|
||||
const EP_OUT = 1;
|
||||
const EP_IN = 1;
|
||||
|
||||
function log(...args) {
|
||||
logEl.textContent += args.join(" ") + "\n";
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
function setStatus(text, mode = "") {
|
||||
connStatusEl.textContent = text;
|
||||
connStatusEl.className = `status ${mode}`.trim();
|
||||
}
|
||||
|
||||
function hex(bytes) {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function utf8(s) {
|
||||
return new TextEncoder().encode(s);
|
||||
}
|
||||
|
||||
function be32(n) {
|
||||
return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
||||
}
|
||||
|
||||
async function sha256Hex(dataBytes) {
|
||||
const h = await crypto.subtle.digest("SHA-256", dataBytes);
|
||||
return hex(new Uint8Array(h));
|
||||
}
|
||||
|
||||
function getIndexOptions() {
|
||||
const raw = Number.parseInt(String(keyIndexEl.value ?? "0"), 10);
|
||||
const index = Number.isFinite(raw) && raw >= 0 ? raw : 0;
|
||||
return { nostr_index: index };
|
||||
}
|
||||
|
||||
function pretty(value) {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildAuth(method, params) {
|
||||
// Demo caller key only (not secret in browser demo).
|
||||
const callerPriv = Uint8Array.from({ length: 32 }, (_, i) => i + 1);
|
||||
const callerPubX = hex(schnorr.getPublicKey(callerPriv));
|
||||
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
const paramsJson = JSON.stringify(params);
|
||||
const bodyHash = await sha256Hex(utf8(paramsJson));
|
||||
const tags = [
|
||||
["nsigner_rpc", "1"],
|
||||
["nsigner_method", method],
|
||||
["nsigner_body_hash", bodyHash],
|
||||
];
|
||||
|
||||
const content = "webusb-demo";
|
||||
const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
|
||||
const id = await sha256Hex(utf8(ser));
|
||||
const sigBytes = await schnorr.sign(id, callerPriv, new Uint8Array(32));
|
||||
const sigHex = typeof sigBytes === "string" ? sigBytes : hex(sigBytes);
|
||||
|
||||
return {
|
||||
id,
|
||||
pubkey: callerPubX,
|
||||
created_at: createdAt,
|
||||
kind: 27235,
|
||||
tags,
|
||||
content,
|
||||
sig: sigHex,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendRpc(reqObj) {
|
||||
const body = utf8(JSON.stringify(reqObj));
|
||||
const frame = new Uint8Array(4 + body.length);
|
||||
frame.set(be32(body.length), 0);
|
||||
frame.set(body, 4);
|
||||
await dev.transferOut(EP_OUT, frame);
|
||||
|
||||
const deadline = Date.now() + 10000;
|
||||
let ring = new Uint8Array(0);
|
||||
while (Date.now() < deadline) {
|
||||
const r = await dev.transferIn(EP_IN, 512);
|
||||
if (!r.data || r.data.byteLength === 0) continue;
|
||||
|
||||
const chunk = new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength);
|
||||
const next = new Uint8Array(ring.length + chunk.length);
|
||||
next.set(ring, 0);
|
||||
next.set(chunk, ring.length);
|
||||
ring = next;
|
||||
|
||||
while (ring.length >= 4) {
|
||||
const n = (ring[0] << 24) | (ring[1] << 16) | (ring[2] << 8) | ring[3];
|
||||
if (n <= 0 || n > 1_000_000) {
|
||||
ring = ring.slice(1);
|
||||
continue;
|
||||
}
|
||||
if (ring.length < 4 + n) break;
|
||||
|
||||
const payload = ring.slice(4, 4 + n);
|
||||
ring = ring.slice(4 + n);
|
||||
const txt = new TextDecoder().decode(payload);
|
||||
return JSON.parse(txt);
|
||||
}
|
||||
}
|
||||
throw new Error("Timed out waiting for framed response");
|
||||
}
|
||||
|
||||
async function rpcCall(method, params, id = "web-1") {
|
||||
const auth = await buildAuth(method, params);
|
||||
const req = { jsonrpc: "2.0", id, method, params, auth };
|
||||
log("→", method, JSON.stringify(params));
|
||||
const resp = await sendRpc(req);
|
||||
log("←", method, JSON.stringify(resp));
|
||||
return resp;
|
||||
}
|
||||
|
||||
function requirePeerHex(peer) {
|
||||
const v = String(peer || "").trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/.test(v)) {
|
||||
throw new Error("Peer pubkey must be exactly 64 hex chars (x-only pubkey)");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function requireStringResult(resp, what) {
|
||||
if (resp && typeof resp.result === "string") {
|
||||
return resp.result;
|
||||
}
|
||||
throw new Error(`${what} failed: ${pretty(resp)}`);
|
||||
}
|
||||
|
||||
async function fetchOwnPubkeyAndFillPeers() {
|
||||
const params = [getIndexOptions()];
|
||||
const resp = await rpcCall("nostr_get_public_key", params, "web-own-pubkey");
|
||||
if (resp && typeof resp.result === "string") {
|
||||
ownPubkey = resp.result.trim().toLowerCase();
|
||||
pubkeyOutEl.textContent = pretty(resp);
|
||||
if (/^[0-9a-f]{64}$/.test(ownPubkey)) {
|
||||
nip04PeerEl.value = ownPubkey;
|
||||
nip04DecPeerEl.value = ownPubkey;
|
||||
nip44PeerEl.value = ownPubkey;
|
||||
nip44DecPeerEl.value = ownPubkey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
dev = await navigator.usb.requestDevice({ filters: [{}] });
|
||||
await dev.open();
|
||||
if (dev.configuration === null) {
|
||||
await dev.selectConfiguration(1);
|
||||
}
|
||||
|
||||
const intf = dev.configuration.interfaces.find(i =>
|
||||
i.alternates.some(a => a.interfaceClass === 0xff)
|
||||
);
|
||||
if (!intf) throw new Error("No vendor WebUSB interface found");
|
||||
|
||||
iface = intf.interfaceNumber;
|
||||
await dev.claimInterface(iface);
|
||||
const alt = intf.alternates.find(a => a.interfaceClass === 0xff);
|
||||
await dev.selectAlternateInterface(iface, alt.alternateSetting);
|
||||
|
||||
await dev.controlTransferOut({
|
||||
requestType: "class",
|
||||
recipient: "interface",
|
||||
request: 0x22,
|
||||
value: 1,
|
||||
index: iface,
|
||||
});
|
||||
|
||||
pubkeyBtn.disabled = false;
|
||||
signKind1Btn.disabled = false;
|
||||
nip04EncBtn.disabled = false;
|
||||
nip04DecBtn.disabled = false;
|
||||
nip44EncBtn.disabled = false;
|
||||
nip44DecBtn.disabled = false;
|
||||
setStatus(`Connected (iface ${iface})`, "ok");
|
||||
log("Connected. Interface", String(iface));
|
||||
|
||||
try {
|
||||
await fetchOwnPubkeyAndFillPeers();
|
||||
log("Default peer pubkeys set to selected signer pubkey");
|
||||
} catch (e) {
|
||||
log("Auto pubkey fetch failed:", String(e));
|
||||
}
|
||||
}
|
||||
|
||||
connectBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await connect();
|
||||
} catch (e) {
|
||||
setStatus("Connect failed", "err");
|
||||
log("Connect failed:", String(e));
|
||||
}
|
||||
});
|
||||
|
||||
pubkeyBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await fetchOwnPubkeyAndFillPeers();
|
||||
} catch (e) {
|
||||
pubkeyOutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
signKind1Btn.addEventListener("click", async () => {
|
||||
try {
|
||||
let tags = [];
|
||||
try {
|
||||
tags = JSON.parse(kind1TagsEl.value || "[]");
|
||||
if (!Array.isArray(tags)) throw new Error("tags must be an array");
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid tags JSON: ${String(e)}`);
|
||||
}
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: String(kind1ContentEl.value || ""),
|
||||
};
|
||||
|
||||
const params = [unsignedEvent, getIndexOptions()];
|
||||
const resp = await rpcCall("nostr_sign_event", params, "web-sign-kind1");
|
||||
signOutEl.textContent = pretty(resp?.result ?? resp);
|
||||
} catch (e) {
|
||||
signOutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
nip04EncBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const peer = requirePeerHex(nip04PeerEl.value);
|
||||
const msg = String(nip04MsgEl.value || "");
|
||||
const params = [peer, msg, getIndexOptions()];
|
||||
const resp = await rpcCall("nostr_nip04_encrypt", params, "web-nip04-enc");
|
||||
nip04OutEl.textContent = pretty(resp?.result ?? resp);
|
||||
if (resp && typeof resp.result === "string") {
|
||||
nip04DecPeerEl.value = peer;
|
||||
nip04CipherEl.value = resp.result;
|
||||
}
|
||||
} catch (e) {
|
||||
nip04OutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
nip04DecBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const peer = requirePeerHex(nip04DecPeerEl.value);
|
||||
const ciphertext = String(nip04CipherEl.value || "");
|
||||
const params = [peer, ciphertext, getIndexOptions()];
|
||||
const resp = await rpcCall("nostr_nip04_decrypt", params, "web-nip04-dec");
|
||||
nip04DecOutEl.textContent = requireStringResult(resp, "NIP-04 decrypt");
|
||||
} catch (e) {
|
||||
nip04DecOutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
nip44EncBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const peer = requirePeerHex(nip44PeerEl.value);
|
||||
const msg = String(nip44MsgEl.value || "");
|
||||
const params = [peer, msg, getIndexOptions()];
|
||||
const resp = await rpcCall("nostr_nip44_encrypt", params, "web-nip44-enc");
|
||||
nip44OutEl.textContent = pretty(resp?.result ?? resp);
|
||||
if (resp && typeof resp.result === "string") {
|
||||
nip44DecPeerEl.value = peer;
|
||||
nip44CipherEl.value = resp.result;
|
||||
}
|
||||
} catch (e) {
|
||||
nip44OutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
nip44DecBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const peer = requirePeerHex(nip44DecPeerEl.value);
|
||||
const ciphertext = String(nip44CipherEl.value || "");
|
||||
const params = [peer, ciphertext, getIndexOptions()];
|
||||
const resp = await rpcCall("nostr_nip44_decrypt", params, "web-nip44-dec");
|
||||
nip44DecOutEl.textContent = requireStringResult(resp, "NIP-44 decrypt");
|
||||
} catch (e) {
|
||||
nip44DecOutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,252 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Embedded NOSTR_LOGIN_LITE</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 40px;
|
||||
background: white;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 90vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#login-container {
|
||||
/* No styling - let embedded modal blend seamlessly */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div id="login-container">
|
||||
<!-- Login interface will appear here -->
|
||||
</div>
|
||||
|
||||
<div id="test-section" style="display: none; margin-top: 30px;">
|
||||
<h2>Nostr Testing Interface</h2>
|
||||
<div id="status" style="margin-bottom: 20px; padding: 10px; background: #f0f0f0; border-radius: 5px;"></div>
|
||||
|
||||
<div style="display: grid; gap: 15px;">
|
||||
<button id="sign-button" style="padding: 12px; font-size: 16px; background: #007bff; color: white; border: none; border-radius: 5px; cursor: pointer;">
|
||||
Sign Event
|
||||
</button>
|
||||
|
||||
<button id="nip04-encrypt-button" style="padding: 12px; font-size: 16px; background: #28a745; color: white; border: none; border-radius: 5px; cursor: pointer;">
|
||||
NIP-04 Encrypt
|
||||
</button>
|
||||
|
||||
<button id="nip04-decrypt-button" style="padding: 12px; font-size: 16px; background: #28a745; color: white; border: none; border-radius: 5px; cursor: pointer;">
|
||||
NIP-04 Decrypt
|
||||
</button>
|
||||
|
||||
<button id="nip44-encrypt-button" style="padding: 12px; font-size: 16px; background: #6f42c1; color: white; border: none; border-radius: 5px; cursor: pointer;">
|
||||
NIP-44 Encrypt
|
||||
</button>
|
||||
|
||||
<button id="nip44-decrypt-button" style="padding: 12px; font-size: 16px; background: #6f42c1; color: white; border: none; border-radius: 5px; cursor: pointer;">
|
||||
NIP-44 Decrypt
|
||||
</button>
|
||||
|
||||
<button id="get-pubkey-button" style="padding: 12px; font-size: 16px; background: #17a2b8; color: white; border: none; border-radius: 5px; cursor: pointer;">
|
||||
Get Public Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="results" style="margin-top: 20px; padding: 15px; background: #f8f9fa; border-radius: 5px; font-family: monospace; white-space: pre-wrap; max-height: 400px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="../lite/nostr.bundle.js"></script>
|
||||
<script src="../lite/nostr-lite.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await window.NOSTR_LOGIN_LITE.init({
|
||||
theme: 'default',
|
||||
methods: {
|
||||
extension: true,
|
||||
local: true,
|
||||
seedphrase:true,
|
||||
readonly: true,
|
||||
connect: true,
|
||||
remote: true,
|
||||
otp: true
|
||||
},
|
||||
floatingTab: {
|
||||
enabled: true,
|
||||
hPosition: 1, // 0.0-1.0 or '95%' from left
|
||||
vPosition: 0, // 0.0-1.0 or '50%' from top
|
||||
appearance: {
|
||||
style: 'square', // 'pill', 'square', 'circle', 'minimal'
|
||||
// icon: '[LOGIN]', // Now uses text-based icons like [LOGIN], [KEY], [NET]
|
||||
text: 'Login'
|
||||
},
|
||||
behavior: {
|
||||
hideWhenAuthenticated: false,
|
||||
showUserInfo: true,
|
||||
autoSlide: true
|
||||
},
|
||||
animation: {
|
||||
slideDirection: 'auto' // 'auto', 'left', 'right', 'up', 'down'
|
||||
}
|
||||
|
||||
}});
|
||||
|
||||
// Check for existing authentication state on page load
|
||||
const authState = getAuthState();
|
||||
if (authState && authState.method) {
|
||||
console.log('Found existing authentication:', authState.method);
|
||||
document.getElementById('status').textContent = `Authenticated with: ${authState.method}`;
|
||||
document.getElementById('test-section').style.display = 'block';
|
||||
|
||||
// Store some test data for encryption/decryption
|
||||
window.testCiphertext = null;
|
||||
window.testCiphertext44 = null;
|
||||
}
|
||||
|
||||
// Listen for authentication events
|
||||
window.addEventListener('nlMethodSelected', (event) => {
|
||||
console.log('User authenticated:', event.detail);
|
||||
document.getElementById('status').textContent = `Authenticated with: ${event.detail.method}`;
|
||||
document.getElementById('test-section').style.display = 'block';
|
||||
|
||||
// Store some test data for encryption/decryption
|
||||
window.testCiphertext = null;
|
||||
window.testCiphertext44 = null;
|
||||
});
|
||||
|
||||
window.addEventListener('nlLogout', () => {
|
||||
console.log('User logged out');
|
||||
document.getElementById('status').textContent = 'Logged out';
|
||||
document.getElementById('test-section').style.display = 'none';
|
||||
document.getElementById('results').innerHTML = '';
|
||||
});
|
||||
|
||||
// Button event listeners
|
||||
document.getElementById('get-pubkey-button').addEventListener('click', testGetPublicKey);
|
||||
document.getElementById('sign-button').addEventListener('click', testSigning);
|
||||
document.getElementById('nip04-encrypt-button').addEventListener('click', testNip04Encrypt);
|
||||
document.getElementById('nip04-decrypt-button').addEventListener('click', testNip04Decrypt);
|
||||
document.getElementById('nip44-encrypt-button').addEventListener('click', testNip44Encrypt);
|
||||
document.getElementById('nip44-decrypt-button').addEventListener('click', testNip44Decrypt);
|
||||
});
|
||||
|
||||
// Test functions
|
||||
async function testGetPublicKey() {
|
||||
try {
|
||||
updateResults('🔑 Getting public key...');
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
updateResults(`✅ Public Key: ${pubkey}`);
|
||||
} catch (error) {
|
||||
updateResults(`❌ Get Public Key Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testSigning() {
|
||||
try {
|
||||
updateResults('✍️ Signing event...');
|
||||
|
||||
const event = {
|
||||
kind: 1,
|
||||
content: 'Hello from NOSTR_LOGIN_LITE key test! ' + new Date().toISOString(),
|
||||
tags: [],
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
updateResults(`✅ Event Signed Successfully:\n${JSON.stringify(signedEvent, null, 2)}`);
|
||||
} catch (error) {
|
||||
updateResults(`❌ Sign Event Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testNip04Encrypt() {
|
||||
try {
|
||||
updateResults('🔐 Testing NIP-04 encryption...');
|
||||
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
const plaintext = 'Secret message for NIP-04 testing! ' + Date.now();
|
||||
|
||||
const ciphertext = await window.nostr.nip04.encrypt(pubkey, plaintext);
|
||||
window.testCiphertext = ciphertext; // Store for decryption test
|
||||
|
||||
updateResults(`✅ NIP-04 Encrypted:\nPlaintext: ${plaintext}\nCiphertext: ${ciphertext}`);
|
||||
} catch (error) {
|
||||
updateResults(`❌ NIP-04 Encrypt Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testNip04Decrypt() {
|
||||
try {
|
||||
if (!window.testCiphertext) {
|
||||
updateResults('❌ No ciphertext available. Run NIP-04 encrypt first.');
|
||||
return;
|
||||
}
|
||||
|
||||
updateResults('🔓 Testing NIP-04 decryption...');
|
||||
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
const decrypted = await window.nostr.nip04.decrypt(pubkey, window.testCiphertext);
|
||||
|
||||
updateResults(`✅ NIP-04 Decrypted:\nCiphertext: ${window.testCiphertext}\nDecrypted: ${decrypted}`);
|
||||
} catch (error) {
|
||||
updateResults(`❌ NIP-04 Decrypt Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testNip44Encrypt() {
|
||||
try {
|
||||
updateResults('🔐 Testing NIP-44 encryption...');
|
||||
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
const plaintext = 'Secret message for NIP-44 testing! ' + Date.now();
|
||||
|
||||
const ciphertext = await window.nostr.nip44.encrypt(pubkey, plaintext);
|
||||
window.testCiphertext44 = ciphertext; // Store for decryption test
|
||||
|
||||
updateResults(`✅ NIP-44 Encrypted:\nPlaintext: ${plaintext}\nCiphertext: ${ciphertext}`);
|
||||
} catch (error) {
|
||||
updateResults(`❌ NIP-44 Encrypt Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testNip44Decrypt() {
|
||||
try {
|
||||
if (!window.testCiphertext44) {
|
||||
updateResults('❌ No ciphertext available. Run NIP-44 encrypt first.');
|
||||
return;
|
||||
}
|
||||
|
||||
updateResults('🔓 Testing NIP-44 decryption...');
|
||||
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
const decrypted = await window.nostr.nip44.decrypt(pubkey, window.testCiphertext44);
|
||||
|
||||
updateResults(`✅ NIP-44 Decrypted:\nCiphertext: ${window.testCiphertext44}\nDecrypted: ${decrypted}`);
|
||||
} catch (error) {
|
||||
updateResults(`❌ NIP-44 Decrypt Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function updateResults(message) {
|
||||
const results = document.getElementById('results');
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
results.textContent += `[${timestamp}] ${message}\n\n`;
|
||||
results.scrollTop = results.scrollHeight;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+106
-353
@@ -1,5 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -9,249 +10,42 @@
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background: #ffffff;
|
||||
min-height: 100vh;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
background: linear-gradient(45deg, #fff, #007bff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
margin: 15px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status.logged-out {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.status.logged-in {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
border: 1px solid rgba(76, 175, 80, 0.5);
|
||||
}
|
||||
|
||||
.status.loading {
|
||||
background: rgba(255, 193, 7, 0.2);
|
||||
border: 1px solid rgba(255, 193, 7, 0.5);
|
||||
}
|
||||
|
||||
.button {
|
||||
background: linear-gradient(45deg, #007bff, #0056b3);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 15px 30px;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-block;
|
||||
margin: 10px 5px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.4);
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: linear-gradient(45deg, #6c757d, #495057);
|
||||
}
|
||||
|
||||
.button.secondary:hover {
|
||||
box-shadow: 0 4px 15px rgba(108, 117, 125, 0.4);
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
background: linear-gradient(45deg, #dc3545, #c82333);
|
||||
}
|
||||
|
||||
.button.danger:hover {
|
||||
box-shadow: 0 4px 15px rgba(220, 53, 69, 0.4);
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 15px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 15px;
|
||||
display: block;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.profile-about {
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.profile-pubkey {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.console-output {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.console-entry {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.console-timestamp {
|
||||
color: #ccc;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.info-section h3 {
|
||||
margin-top: 0;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.methods-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.methods-list li {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
margin: 10px 0;
|
||||
padding: 10px 15px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #007bff;
|
||||
color: #000000;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔐 NOSTR_LOGIN_LITE - All Login Methods Test</h1>
|
||||
|
||||
<div id="status" class="status logged-out">
|
||||
⏳ Initializing NOSTR_LOGIN_LITE...
|
||||
</div>
|
||||
|
||||
<div id="login-section">
|
||||
<button id="launch-modal" class="button">🚀 Launch Login Modal</button>
|
||||
<p style="font-size: 14px; opacity: 0.8; margin-top: 10px;">
|
||||
Click to open the NOSTR_LOGIN_LITE modal and test all available login methods:
|
||||
</p>
|
||||
|
||||
<div class="info-section">
|
||||
<h3>Available Login Methods</h3>
|
||||
<ul class="methods-list">
|
||||
<li><strong>Browser Extension:</strong> Alby, nos2x, etc. (if installed)</li>
|
||||
<li><strong>Local Key:</strong> Generate new keys or import existing private key/nsec</li>
|
||||
<li><strong>Read Only:</strong> Access public content without authentication</li>
|
||||
<li><strong>Nostr Connect (NIP-46):</strong> Connect to remote signing services</li>
|
||||
<li><strong>DM/OTP:</strong> Secure local accounts with one-time passwords</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="profile-section" style="display: none;">
|
||||
<div class="profile-card">
|
||||
<div class="profile-header">
|
||||
<img id="profile-picture" class="profile-avatar" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iNDAiIGN5PSI0MCIgcj0iNDAiIGZpbGw9IiNmNmY2ZjYiLz4KPHBhdGggZD0iTTQwIDQ1QzQ1IDQ1IDUwIDQyIDUwIDQwUzQ1IDQxIDQwIDQ1WiIgc3Ryb2tlPSIjYmJkIiBzdHJva2Utd2lkdGg9IjIiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPgo8Y2lyY2xlIGN4PSIzNSIgY3k9IjM1IiByPSIyIiBmaWxsPSIjYmJkIi8+CjxjaXJjbGUgY3g9IjQ1IiBjeT0iMzUiIHI9IjIiIGZpbGw9IiNiYmQiLz4KPGRlZnM+Cj08L2RlZnM+Cjwvc3ZnPgo=" alt="Profile Picture">
|
||||
<div id="profile-name" class="profile-name">Loading...</div>
|
||||
<div id="profile-about" class="profile-about"></div>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Public Key:</strong>
|
||||
<div id="profile-pubkey" class="profile-pubkey"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="refresh-profile" class="button secondary">🔄 Refresh Profile</button>
|
||||
<button id="logout" class="button danger">🚪 Logout</button>
|
||||
</div>
|
||||
|
||||
<div class="console-output" id="console-display">
|
||||
<div class="console-entry">
|
||||
<span class="console-timestamp">[Console]</span> Ready for testing
|
||||
</div>
|
||||
</div>
|
||||
<div id="login-section">
|
||||
<!-- Login UI if needed -->
|
||||
</div>
|
||||
<div id="profile-section">
|
||||
<img id="profile-picture">
|
||||
<div id="profile-pubkey"></div>
|
||||
<div id="profile-name"></div>
|
||||
<div id="profile-about"></div>
|
||||
</div>
|
||||
|
||||
<!-- Load the official nostr-tools bundle first -->
|
||||
<script src="../lite/nostr.bundle.js"></script>
|
||||
|
||||
|
||||
<!-- Load NOSTR_LOGIN_LITE main library (now includes NIP-46 extension) -->
|
||||
<script src="../lite/nostr-lite.js"></script>
|
||||
|
||||
<!-- Load the official nostr-tools bundle first -->
|
||||
<!-- <script src="./nostr.bundle.js"></script> -->
|
||||
<script src="https://laantungir.net/nostr-login-lite/nostr.bundle.js"></script>
|
||||
|
||||
<!-- Load NOSTR_LOGIN_LITE main library -->
|
||||
<script src="https://laantungir.net/nostr-login-lite/nostr-lite.js"></script>
|
||||
<!-- <script src="./nostr-lite.js"></script> -->
|
||||
|
||||
|
||||
<script>
|
||||
// Console logging helper
|
||||
function log(level, message) {
|
||||
const consoleDiv = document.getElementById('console-display');
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'console-entry';
|
||||
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const prefix = level === 'ERROR' ? '[ERROR]' :
|
||||
level === 'SUCCESS' ? '[SUCCESS]' :
|
||||
level === 'WARNING' ? '[WARNING]' : '[INFO]';
|
||||
|
||||
entry.innerHTML = `<span class="console-timestamp">[${timestamp}] ${prefix}</span> ${message}`;
|
||||
consoleDiv.appendChild(entry);
|
||||
consoleDiv.scrollTop = consoleDiv.scrollHeight;
|
||||
}
|
||||
|
||||
// Global variables
|
||||
let nlLite = null;
|
||||
@@ -260,155 +54,121 @@
|
||||
|
||||
// Initialize NOSTR_LOGIN_LITE
|
||||
async function initializeApp() {
|
||||
log('INFO', 'Initializing NOSTR_LOGIN_LITE...');
|
||||
// console.log('INFO', 'Initializing NOSTR_LOGIN_LITE...');
|
||||
|
||||
try {
|
||||
await window.NOSTR_LOGIN_LITE.init({
|
||||
theme: 'dark',
|
||||
persistence: true, // Enable persistent authentication (default: true)
|
||||
isolateSession: true, // Use sessionStorage for per-tab isolation (default: false = localStorage)
|
||||
theme: 'default',
|
||||
darkMode: false,
|
||||
relays: [relayUrl, 'wss://relay.damus.io'],
|
||||
methods: {
|
||||
extension: true,
|
||||
local: true,
|
||||
readonly: true,
|
||||
remote: true, // Enables "Nostr Connect" (NIP-46)
|
||||
seedphrase: true,
|
||||
connect: true, // Enables "Nostr Connect" (NIP-46)
|
||||
remote: true, // Also needed for "Nostr Connect" compatibility
|
||||
otp: true // Enables "DM/OTP"
|
||||
},
|
||||
floatingTab: {
|
||||
enabled: true,
|
||||
hPosition: .98, // 95% from left
|
||||
vPosition: 0, // 50% from top (center)
|
||||
getUserInfo: true, // Fetch user profiles
|
||||
getUserRelay: ['wss://relay.laantungir.net'], // Custom relays for profiles
|
||||
appearance: {
|
||||
style: 'minimal',
|
||||
theme: 'auto',
|
||||
icon: '',
|
||||
text: 'Login',
|
||||
iconOnly: false
|
||||
},
|
||||
behavior: {
|
||||
hideWhenAuthenticated: false,
|
||||
showUserInfo: true,
|
||||
autoSlide: false,
|
||||
persistent: false
|
||||
},
|
||||
animation: {
|
||||
slideDirection: 'right' // Slide to the right when hiding
|
||||
}
|
||||
},
|
||||
debug: true
|
||||
});
|
||||
|
||||
nlLite = window.NOSTR_LOGIN_LITE;
|
||||
log('SUCCESS', 'NOSTR_LOGIN_LITE initialized successfully');
|
||||
document.getElementById('status').innerHTML = '✅ Ready - Click "Launch Login Modal" to test all login methods';
|
||||
document.getElementById('status').className = 'status logged-in';
|
||||
console.log('SUCCESS', 'NOSTR_LOGIN_LITE initialized successfully');
|
||||
|
||||
window.addEventListener('nlMethodSelected', handleAuthEvent);
|
||||
window.addEventListener('nlLogout', handleLogoutEvent);
|
||||
|
||||
} catch (error) {
|
||||
log('ERROR', `Initialization failed: ${error.message}`);
|
||||
document.getElementById('status').innerHTML = '❌ Failed to initialize NOSTR_LOGIN_LITE';
|
||||
document.getElementById('status').className = 'status logged-out';
|
||||
console.log('ERROR', `Initialization failed: ${error.message}`);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Launch the login modal
|
||||
async function launchLoginModal() {
|
||||
log('INFO', 'Launching NOSTR_LOGIN_LITE modal...');
|
||||
document.getElementById('status').innerHTML = '🔄 Opening login modal...';
|
||||
document.getElementById('status').className = 'status loading';
|
||||
|
||||
try {
|
||||
// Launch the modal
|
||||
await nlLite.launch('login');
|
||||
log('SUCCESS', 'Login modal launched successfully');
|
||||
|
||||
} catch (error) {
|
||||
log('ERROR', `Failed to launch modal: ${error.message}`);
|
||||
document.getElementById('status').innerHTML = '❌ Failed to launch modal';
|
||||
document.getElementById('status').className = 'status logged-out';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle authentication events
|
||||
function handleAuthEvent(event) {
|
||||
const { type, pubkey, method, error } = event.detail;
|
||||
|
||||
log('INFO', `Auth event received: type=${type}, method=${method}`);
|
||||
|
||||
if (type === 'login' && pubkey) {
|
||||
const { pubkey, method, error } = event.detail;
|
||||
console.log('INFO', `Auth event received: method=${method}`);
|
||||
|
||||
if (method && pubkey) {
|
||||
userPubkey = pubkey;
|
||||
log('SUCCESS', `Login successful! Method: ${method}, Pubkey: ${pubkey}`);
|
||||
|
||||
document.getElementById('status').innerHTML = `✅ Logged in via ${method}!`;
|
||||
document.getElementById('status').className = 'status logged-in';
|
||||
|
||||
// Show profile section
|
||||
document.getElementById('login-section').style.display = 'none';
|
||||
document.getElementById('profile-section').style.display = 'block';
|
||||
|
||||
// Load profile
|
||||
console.log('SUCCESS', `Login successful! Method: ${method}, Pubkey: ${pubkey}`);
|
||||
|
||||
loadUserProfile();
|
||||
|
||||
} else if (type === 'logout') {
|
||||
log('INFO', 'User logged out');
|
||||
userPubkey = null;
|
||||
|
||||
document.getElementById('status').innerHTML = '✅ Ready - Click "Launch Login Modal" to test all login methods';
|
||||
document.getElementById('status').className = 'status logged-in';
|
||||
|
||||
// Show login section
|
||||
document.getElementById('login-section').style.display = 'block';
|
||||
document.getElementById('profile-section').style.display = 'none';
|
||||
|
||||
|
||||
} else if (error) {
|
||||
log('ERROR', `Authentication error: ${error}`);
|
||||
document.getElementById('status').innerHTML = '❌ Authentication failed';
|
||||
document.getElementById('status').className = 'status logged-out';
|
||||
console.log('ERROR', `Authentication error: ${error}`);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Load user profile
|
||||
function handleLogoutEvent() {
|
||||
console.log('INFO', 'Logout event received');
|
||||
// Clear local UI state
|
||||
userPubkey = null;
|
||||
document.getElementById('profile-name').textContent = '';
|
||||
document.getElementById('profile-about').textContent = '';
|
||||
document.getElementById('profile-pubkey').textContent = '';
|
||||
document.getElementById('profile-picture').src = '';
|
||||
}
|
||||
|
||||
// Load user profile using nostr-tools pool
|
||||
async function loadUserProfile() {
|
||||
if (!userPubkey) return;
|
||||
|
||||
log('INFO', `Loading profile for: ${userPubkey}`);
|
||||
console.log('INFO', `Loading profile for: ${userPubkey}`);
|
||||
document.getElementById('profile-name').textContent = 'Loading profile...';
|
||||
document.getElementById('profile-pubkey').textContent = userPubkey;
|
||||
|
||||
try {
|
||||
// Simple WebSocket connection to get profile
|
||||
const ws = new WebSocket(relayUrl);
|
||||
// Create a SimplePool instance
|
||||
const pool = new window.NostrTools.SimplePool();
|
||||
const relays = [relayUrl, 'wss://relay.laantungir.net'];
|
||||
|
||||
ws.onopen = () => {
|
||||
log('SUCCESS', 'WebSocket connected, requesting profile...');
|
||||
const req = JSON.stringify([
|
||||
'REQ',
|
||||
'profile',
|
||||
{
|
||||
kinds: [0],
|
||||
authors: [userPubkey],
|
||||
limit: 1
|
||||
}
|
||||
]);
|
||||
ws.send(req);
|
||||
};
|
||||
// Get profile event (kind 0) for the user using querySync
|
||||
const events = await pool.querySync(relays, {
|
||||
kinds: [0],
|
||||
authors: [userPubkey],
|
||||
limit: 1
|
||||
});
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
const [type, subscriptionId, eventData] = message;
|
||||
pool.close(relays); // Clean up connections
|
||||
|
||||
if (type === 'EVENT' && eventData && eventData.kind === 0) {
|
||||
log('SUCCESS', 'Profile event received');
|
||||
const profile = JSON.parse(eventData.content);
|
||||
displayProfile(profile);
|
||||
ws.close();
|
||||
} else if (type === 'EOSE') {
|
||||
log('INFO', 'End of subscription');
|
||||
ws.close();
|
||||
}
|
||||
} catch (parseError) {
|
||||
log('ERROR', `Failed to parse WebSocket message: ${parseError.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
log('ERROR', `WebSocket error: ${error.message || 'Connection failed'}`);
|
||||
document.getElementById('profile-name').textContent = 'Error loading profile';
|
||||
};
|
||||
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (ws.readyState !== WebSocket.CLOSED) {
|
||||
ws.close();
|
||||
if (document.getElementById('profile-name').textContent === 'Loading profile...') {
|
||||
document.getElementById('profile-name').textContent = 'Profile timeout';
|
||||
document.getElementById('profile-about').textContent = 'Could not load profile from relay.';
|
||||
log('WARNING', 'Profile request timed out');
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
if (events.length > 0) {
|
||||
console.log('SUCCESS', 'Profile event received');
|
||||
const profile = JSON.parse(events[0].content);
|
||||
displayProfile(profile);
|
||||
} else {
|
||||
console.log('INFO', 'No profile found');
|
||||
document.getElementById('profile-name').textContent = 'No profile found';
|
||||
document.getElementById('profile-about').textContent = 'User has not set up a profile yet.';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log('ERROR', `Profile loading failed: ${error.message}`);
|
||||
console.log('ERROR', `Profile loading failed: ${error.message}`);
|
||||
document.getElementById('profile-name').textContent = 'Error loading profile';
|
||||
document.getElementById('profile-about').textContent = error.message;
|
||||
}
|
||||
@@ -427,33 +187,26 @@
|
||||
document.getElementById('profile-picture').src = picture;
|
||||
}
|
||||
|
||||
log('SUCCESS', `Profile displayed: ${name}`);
|
||||
console.log('SUCCESS', `Profile displayed: ${name}`);
|
||||
}
|
||||
|
||||
// Logout function
|
||||
async function logout() {
|
||||
log('INFO', 'Logging out...');
|
||||
console.log('INFO', 'Logging out...');
|
||||
try {
|
||||
await nlLite.logout();
|
||||
log('SUCCESS', 'Logged out successfully');
|
||||
window.NOSTR_LOGIN_LITE.logout();
|
||||
console.log('SUCCESS', 'Logged out successfully');
|
||||
} catch (error) {
|
||||
log('ERROR', `Logout failed: ${error.message}`);
|
||||
console.log('ERROR', `Logout failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event listeners
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Button event listeners
|
||||
document.getElementById('launch-modal').addEventListener('click', launchLoginModal);
|
||||
document.getElementById('refresh-profile').addEventListener('click', loadUserProfile);
|
||||
document.getElementById('logout').addEventListener('click', logout);
|
||||
|
||||
// Listen for authentication events
|
||||
window.addEventListener('nlAuth', handleAuthEvent);
|
||||
|
||||
// Initialize the app
|
||||
setTimeout(initializeApp, 100);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,411 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🔐 NOSTR_LOGIN_LITE - Full Modal Login Demo</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
background: linear-gradient(45deg, #fff, #007bff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.demo-section {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 15px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.demo-section h2 {
|
||||
margin-top: 0;
|
||||
font-size: 24px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: linear-gradient(45deg, #007bff, #0056b3);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 15px 30px;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-block;
|
||||
margin: 10px 5px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.4);
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: linear-gradient(45deg, #6c757d, #495057);
|
||||
}
|
||||
|
||||
.button.secondary:hover {
|
||||
box-shadow: 0 4px 15px rgba(108, 117, 125, 0.4);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status.success { background: rgba(76, 175, 80, 0.2); color: #81c784; }
|
||||
.status.error { background: rgba(244, 67, 54, 0.2); color: #ef5350; }
|
||||
.status.warning { background: rgba(255, 193, 7, 0.2); color: #ffd54f; }
|
||||
.status.info { background: rgba(33, 150, 243, 0.2); color: #64b5f6; }
|
||||
|
||||
.console-output {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.console-entry {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.console-timestamp {
|
||||
color: #ccc;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.feature-item .icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.feature-item h3 {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.feature-item p {
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔐 NOSTR_LOGIN_LITE Full Modal Login Demo</h1>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2>📚 Available Login Methods</h2>
|
||||
<p>This demo showcases all login methods provided by NOSTR_LOGIN_LITE:</p>
|
||||
|
||||
<div class="feature-list">
|
||||
<div class="feature-item">
|
||||
<div class="icon">📱</div>
|
||||
<h3>Extension Login</h3>
|
||||
<p>Use browser extensions like Alby, nos2x, or other Nostr-compatible extensions</p>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="icon">💾</div>
|
||||
<h3>Local Account</h3>
|
||||
<p>Create and manage local Nostr keypairs stored in browser storage</p>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="icon">👁️</div>
|
||||
<h3>Read-Only Account</h3>
|
||||
<p>Access public content without authentication (limited functionality)</p>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="icon">🔗</div>
|
||||
<h3>NIP-46 Remote</h3>
|
||||
<p>Connect to remote signers for secure key management</p>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="icon">🔐</div>
|
||||
<h3>OTP Backup</h3>
|
||||
<p>Secure local accounts with time-based one-time passwords</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Library Status -->
|
||||
<div class="demo-section">
|
||||
<h2>⚙️ Library Status</h2>
|
||||
<div id="dep-status" class="status info">Loading nostr-tools...</div>
|
||||
<div id="lib-status" class="status info">Loading NOSTR_LOGIN_LITE...</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Authentication -->
|
||||
<div class="demo-section">
|
||||
<h2>🎯 Launch Full Login Modal</h2>
|
||||
<p>Click the button below to launch the complete authentication modal with all available login options:</p>
|
||||
<button id="launch-auth" class="button">🚀 Launch Authentication Modal</button>
|
||||
<button onclick="location.reload()" class="button secondary">🔄 Reload Page</button>
|
||||
<div id="auth-status" class="status" style="margin-top: 15px;">Ready to authenticate...</div>
|
||||
<div style="font-size: 14px; opacity: 0.8; margin-top: 10px;">
|
||||
The modal will show all available login methods based on your browser setup and library configuration.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Info Display (shown after login) -->
|
||||
<div id="user-info" class="demo-section" style="display: none;">
|
||||
<h2>👤 User Information</h2>
|
||||
<div id="user-details">
|
||||
<strong>Public Key:</strong> <span id="user-pubkey">Loading...</span><br>
|
||||
<strong>Login Method:</strong> <span id="user-method">Loading...</span><br>
|
||||
<strong>Account Type:</strong> <span id="user-type">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Console Log -->
|
||||
<div class="console-output" id="console-display">
|
||||
<div class="console-entry">
|
||||
<span class="console-timestamp">[Demo]</span> Modal Login Demo initialized
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load the official nostr-tools bundle first -->
|
||||
<script src="../lite/nostr.bundle.js"></script>
|
||||
|
||||
<!-- Load NOSTR_LOGIN_LITE main library (now includes NIP-46 extension) -->
|
||||
<script src="../lite/nostr-lite.js"></script>
|
||||
|
||||
<script>
|
||||
// Console logging helper
|
||||
function addConsoleEntry(message, type = 'info') {
|
||||
const consoleDiv = document.getElementById('console-display');
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'console-entry';
|
||||
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const prefix = type === 'error' ? '[ERROR]' :
|
||||
type === 'success' ? '[SUCCESS]' :
|
||||
type === 'warning' ? '[WARNING]' : '[INFO]';
|
||||
|
||||
entry.innerHTML = `<span class="console-timestamp">[${timestamp}] ${prefix}</span> ${message}`;
|
||||
consoleDiv.appendChild(entry);
|
||||
consoleDiv.scrollTop = consoleDiv.scrollHeight;
|
||||
}
|
||||
|
||||
// Global state
|
||||
let authInitialized = false;
|
||||
|
||||
// Event listeners for authentication events
|
||||
window.addEventListener('nlAuth', (event) => {
|
||||
addConsoleEntry(`Authentication event: ${event.detail.type}`, 'success');
|
||||
if (event.detail.pubkey) {
|
||||
addConsoleEntry(`User authenticated: ${event.detail.pubkey}`, 'success');
|
||||
displayUserInfo(event.detail);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('nlLogout', (event) => {
|
||||
addConsoleEntry('User logged out', 'warning');
|
||||
hideUserInfo();
|
||||
});
|
||||
|
||||
window.addEventListener('nlAuthUrl', (event) => {
|
||||
addConsoleEntry(`Auth URL generated: ${event.detail.url}`, 'info');
|
||||
});
|
||||
|
||||
window.addEventListener('nlError', (event) => {
|
||||
addConsoleEntry(`Authentication error: ${event.detail.message}`, 'error');
|
||||
});
|
||||
|
||||
// Library load checking with retry
|
||||
function checkLibraryLoaded() {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50; // 5 seconds
|
||||
|
||||
const check = () => {
|
||||
if (window.NostrTools) {
|
||||
document.getElementById('dep-status').textContent = '✓ nostr-tools loaded successfully!';
|
||||
document.getElementById('dep-status').className = 'status success';
|
||||
}
|
||||
|
||||
if (window.NOSTR_LOGIN_LITE) {
|
||||
document.getElementById('lib-status').textContent = '✓ NOSTR_LOGIN_LITE loaded successfully!';
|
||||
document.getElementById('lib-status').className = 'status success';
|
||||
enableModalLaunch();
|
||||
} else {
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(check, 100);
|
||||
} else {
|
||||
document.getElementById('lib-status').textContent = '✗ Failed to load NOSTR_LOGIN_LITE';
|
||||
document.getElementById('lib-status').className = 'status error';
|
||||
addConsoleEntry('Bundle might have JavaScript errors - check browser console', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
check();
|
||||
}
|
||||
|
||||
// Enable the modal launch button
|
||||
function enableModalLaunch() {
|
||||
const launchBtn = document.getElementById('launch-auth');
|
||||
launchBtn.disabled = false;
|
||||
launchBtn.textContent = '🚀 Launch Authentication Modal';
|
||||
addConsoleEntry('Full modal authentication ready', 'success');
|
||||
}
|
||||
|
||||
// Launch authentication modal
|
||||
async function launchAuthModal() {
|
||||
const launchBtn = document.getElementById('launch-auth');
|
||||
const status = document.getElementById('auth-status');
|
||||
|
||||
try {
|
||||
status.textContent = '🔄 Initializing authentication...';
|
||||
status.className = 'status warning';
|
||||
launchBtn.disabled = true;
|
||||
|
||||
// Initialize NOSTR_LOGIN_LITE with all methods enabled
|
||||
const options = {
|
||||
theme: 'dark',
|
||||
darkMode: false,
|
||||
relays: ['wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol'],
|
||||
methods: {
|
||||
extension: true,
|
||||
local: true,
|
||||
readonly: true,
|
||||
remote: true,
|
||||
otp: true
|
||||
},
|
||||
debug: true
|
||||
};
|
||||
|
||||
addConsoleEntry('Initializing NOSTR_LOGIN_LITE with full configuration', 'info');
|
||||
|
||||
if (!authInitialized) {
|
||||
await window.NOSTR_LOGIN_LITE.init(options);
|
||||
authInitialized = true;
|
||||
}
|
||||
|
||||
addConsoleEntry('Launching full authentication modal', 'info');
|
||||
status.textContent = '🎨 Opening authentication modal...';
|
||||
|
||||
// Launch the modal - this will show all available methods
|
||||
window.NOSTR_LOGIN_LITE.launch('login');
|
||||
|
||||
status.textContent = '✅ Authentication modal launched!';
|
||||
status.className = 'status success';
|
||||
|
||||
addConsoleEntry('Modal launched successfully - all login methods available', 'success');
|
||||
|
||||
// Re-enable button after a delay
|
||||
setTimeout(() => {
|
||||
launchBtn.disabled = false;
|
||||
launchBtn.textContent = '🔄 Launch Again';
|
||||
status.textContent = 'Ready to launch modal again...';
|
||||
status.className = 'status info';
|
||||
}, 3000);
|
||||
|
||||
} catch (error) {
|
||||
addConsoleEntry(`Modal launch failed: ${error.message}`, 'error');
|
||||
status.textContent = '❌ Failed to launch modal';
|
||||
status.className = 'status error';
|
||||
launchBtn.disabled = false;
|
||||
launchBtn.textContent = '🚀 Try Again';
|
||||
}
|
||||
}
|
||||
|
||||
// Display user information after successful authentication
|
||||
function displayUserInfo(details) {
|
||||
document.getElementById('user-info').style.display = 'block';
|
||||
document.getElementById('user-pubkey').textContent = details.pubkey || 'Unknown';
|
||||
document.getElementById('user-method').textContent = details.method || 'Unknown';
|
||||
document.getElementById('user-type').textContent = getAccountType(details.method);
|
||||
|
||||
const status = document.getElementById('auth-status');
|
||||
status.textContent = '✅ Successfully authenticated!';
|
||||
status.className = 'status success';
|
||||
}
|
||||
|
||||
// Hide user info on logout
|
||||
function hideUserInfo() {
|
||||
document.getElementById('user-info').style.display = 'none';
|
||||
|
||||
const status = document.getElementById('auth-status');
|
||||
status.textContent = '👋 User logged out';
|
||||
status.className = 'status warning';
|
||||
}
|
||||
|
||||
// Helper function to get readable account type
|
||||
function getAccountType(method) {
|
||||
const types = {
|
||||
extension: 'Browser Extension',
|
||||
local: 'Local Account',
|
||||
readonly: 'Read-Only Account',
|
||||
remote: 'NIP-46 Remote',
|
||||
otp: 'OTP Secured Local'
|
||||
};
|
||||
return types[method] || 'Unknown';
|
||||
}
|
||||
|
||||
// Initialize everything when DOM loads
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
addConsoleEntry('Demo page loaded, initializing libraries...', 'info');
|
||||
|
||||
// Check if libraries are loaded
|
||||
checkLibraryLoaded();
|
||||
|
||||
// Set up the modal launch button
|
||||
document.getElementById('launch-auth').addEventListener('click', launchAuthModal);
|
||||
});
|
||||
|
||||
// Debug logging
|
||||
console.log('NOSTR_LOGIN_LITE Modal Demo loaded');
|
||||
console.log('Available login methods will be shown in modal');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Embedded NOSTR_LOGIN_LITE</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 40px;
|
||||
background: white;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 90vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#login-container {
|
||||
/* No styling - let embedded modal blend seamlessly */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
|
||||
<script src="../lite/nostr.bundle.js"></script>
|
||||
<script src="../lite/nostr-lite.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await window.NOSTR_LOGIN_LITE.init({
|
||||
theme: 'default',
|
||||
methods: {
|
||||
extension: true,
|
||||
local: true,
|
||||
seedphrase:true,
|
||||
readonly: true,
|
||||
connect: true,
|
||||
remote: true,
|
||||
otp: true
|
||||
},
|
||||
floatingTab: {
|
||||
enabled: true,
|
||||
hPosition: 1, // 0.0-1.0 or '95%' from left
|
||||
vPosition: 0, // 0.0-1.0 or '50%' from top
|
||||
appearance: {
|
||||
style: 'square', // 'pill', 'square', 'circle', 'minimal'
|
||||
// icon: '[LOGIN]', // Now uses text-based icons like [LOGIN], [KEY], [NET]
|
||||
text: 'Login'
|
||||
},
|
||||
behavior: {
|
||||
hideWhenAuthenticated: false,
|
||||
showUserInfo: true,
|
||||
autoSlide: true
|
||||
},
|
||||
animation: {
|
||||
slideDirection: 'auto' // 'auto', 'left', 'right', 'up', 'down'
|
||||
}
|
||||
|
||||
}});
|
||||
|
||||
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,106 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>n_signer WebUSB Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>n_signer WebUSB Harness</h2>
|
||||
<div id="status"></div>
|
||||
<div id="results"></div>
|
||||
<button id="launch">Launch Login Modal</button>
|
||||
<button id="sign" disabled>Sign kind-1</button>
|
||||
<button id="nip44" disabled>NIP-44 self encrypt/decrypt</button>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script>
|
||||
const status = document.getElementById('status');
|
||||
const results = document.getElementById('results');
|
||||
const signBtn = document.getElementById('sign');
|
||||
const nip44Btn = document.getElementById('nip44');
|
||||
|
||||
function setStatus(msg) {
|
||||
status.textContent = msg;
|
||||
}
|
||||
|
||||
function show(obj) {
|
||||
results.innerHTML = `<pre>${typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2)}</pre>`;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
setStatus('Initializing...');
|
||||
await window.NOSTR_LOGIN_LITE.init({
|
||||
theme: 'default',
|
||||
methods: {
|
||||
// extension: true,
|
||||
// local: true,
|
||||
// readonly: true,
|
||||
// connect: true,
|
||||
// nsigner: true,
|
||||
// otp: false
|
||||
},
|
||||
floatingTab: { enabled: false }
|
||||
});
|
||||
|
||||
if (!window.isSecureContext) {
|
||||
setStatus('Not a secure context. Use HTTPS or localhost for WebUSB.');
|
||||
} else if (!('usb' in navigator)) {
|
||||
setStatus('WebUSB not available in this browser. Use Chrome/Edge.');
|
||||
} else {
|
||||
setStatus('Ready. Use Launch Login Modal and select USB Hardware signer.');
|
||||
}
|
||||
})();
|
||||
|
||||
document.getElementById('launch').addEventListener('click', () => {
|
||||
window.NOSTR_LOGIN_LITE.launch('login');
|
||||
});
|
||||
|
||||
window.addEventListener('nlMethodSelected', (event) => {
|
||||
setStatus(`Authenticated with: ${event.detail.method}`);
|
||||
const enabled = event.detail.method !== 'readonly';
|
||||
signBtn.disabled = !enabled;
|
||||
nip44Btn.disabled = !enabled;
|
||||
});
|
||||
|
||||
window.addEventListener('nlAuthRestored', (event) => {
|
||||
setStatus(`Restored auth: ${event.detail.method}`);
|
||||
const enabled = event.detail.method !== 'readonly';
|
||||
signBtn.disabled = !enabled;
|
||||
nip44Btn.disabled = !enabled;
|
||||
});
|
||||
|
||||
window.addEventListener('nlReconnectionRequired', (event) => {
|
||||
setStatus(event.detail?.message || 'Reconnection required');
|
||||
});
|
||||
|
||||
signBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const event = {
|
||||
kind: 1,
|
||||
content: 'hello from nsigner harness',
|
||||
tags: [],
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
const signed = await window.nostr.signEvent(event);
|
||||
show(signed);
|
||||
} catch (err) {
|
||||
show(`signEvent failed: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
nip44Btn.addEventListener('click', async () => {
|
||||
try {
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
const plaintext = 'self-test ' + Date.now();
|
||||
const cipher = await window.nostr.nip44.encrypt(pubkey, plaintext);
|
||||
const dec = await window.nostr.nip44.decrypt(pubkey, cipher);
|
||||
show({ pubkey, plaintext, cipher, decrypted: dec, ok: dec === plaintext });
|
||||
} catch (err) {
|
||||
show(`nip44 failed: ${err.message}`);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,534 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Session Isolation Test - NOSTR LOGIN LITE</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Courier New', monospace;
|
||||
margin: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
background: #f8f9fa;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #e7f3ff;
|
||||
border: 2px solid #0066cc;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.isolated-notice {
|
||||
background: #f8d7da;
|
||||
border: 2px solid #dc3545;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
button {
|
||||
background: white;
|
||||
color: black;
|
||||
border: 2px solid black;
|
||||
border-radius: 8px;
|
||||
padding: 10px 20px;
|
||||
margin: 5px;
|
||||
cursor: pointer;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: red;
|
||||
}
|
||||
|
||||
button:active {
|
||||
background: red;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mode-indicator {
|
||||
font-weight: bold;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid #dc3545;
|
||||
background: #f8d7da;
|
||||
display: inline-block;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔐 Session Isolation Test</h1>
|
||||
|
||||
<div class="info-box">
|
||||
<h3>📋 Test Instructions</h3>
|
||||
<ol>
|
||||
<li><strong>Isolated Session:</strong> Each tab/window has independent authentication</li>
|
||||
<li>Login in this tab/window - it will persist on refresh</li>
|
||||
<li>Open new windows/tabs - they will start unauthenticated</li>
|
||||
<li>Login with different users in different windows simultaneously</li>
|
||||
<li>Refresh any window - authentication persists within that window only</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="mode-indicator">
|
||||
🔒 ISOLATED MODE (sessionStorage)
|
||||
</div>
|
||||
|
||||
<div class="isolated-notice">
|
||||
<strong>🚨 Session Isolation Active:</strong>
|
||||
<p>This tab uses sessionStorage - authentication is isolated to this window only. Refreshing will maintain your login state, but other tabs/windows are independent.</p>
|
||||
</div>
|
||||
|
||||
<div class="status-panel">
|
||||
<h3>Authentication Status</h3>
|
||||
<div id="auth-status">Not authenticated</div>
|
||||
<div id="auth-details"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Actions</h3>
|
||||
<button onclick="login()">Login</button>
|
||||
<button onclick="logout()">Logout</button>
|
||||
<button onclick="checkStatus()">Check Status</button>
|
||||
<button onclick="testSigning()">Test Signing</button>
|
||||
<button onclick="openNewWindow()">Open New Window</button>
|
||||
<button onclick="debugAuthentication()" style="border-color: orange;">Debug Auth State</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Storage Inspector</h3>
|
||||
<button onclick="inspectStorage()">Inspect SessionStorage</button>
|
||||
<button onclick="clearStorage()">Clear Session Storage</button>
|
||||
<div id="storage-content"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Test Results</h3>
|
||||
<div id="results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="../lite/nostr.bundle.js"></script>
|
||||
<script src="../lite/nostr-lite.js"></script>
|
||||
|
||||
<script>
|
||||
let nostrLiteInstance = null;
|
||||
|
||||
// Initialize in isolated mode (always)
|
||||
initializeIsolatedMode();
|
||||
|
||||
async function initializeIsolatedMode() {
|
||||
try {
|
||||
console.log('Initializing NOSTR_LOGIN_LITE in ISOLATED mode...');
|
||||
|
||||
nostrLiteInstance = await window.NOSTR_LOGIN_LITE.init({
|
||||
theme: 'default',
|
||||
persistence: true,
|
||||
isolateSession: true, // Always isolated - each tab/window independent
|
||||
methods: {
|
||||
extension: true,
|
||||
local: true,
|
||||
readonly: true,
|
||||
connect: true,
|
||||
otp: true
|
||||
},
|
||||
floatingTab: {
|
||||
enabled: true,
|
||||
hPosition: 0.95,
|
||||
vPosition: 0.1,
|
||||
appearance: {
|
||||
style: 'pill',
|
||||
icon: '🔒',
|
||||
text: 'ISOLATED',
|
||||
iconOnly: false
|
||||
},
|
||||
behavior: {
|
||||
hideWhenAuthenticated: false,
|
||||
showUserInfo: true,
|
||||
autoSlide: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
checkStatus();
|
||||
|
||||
console.log('NOSTR_LOGIN_LITE initialized successfully in ISOLATED mode');
|
||||
console.log('Authentication will persist on refresh within this tab only');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize NOSTR_LOGIN_LITE:', error);
|
||||
document.getElementById('results').innerHTML =
|
||||
`<div style="color: red;">Initialization Error: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function login() {
|
||||
window.NOSTR_LOGIN_LITE.launch('login');
|
||||
}
|
||||
|
||||
function logout() {
|
||||
window.NOSTR_LOGIN_LITE.logout();
|
||||
setTimeout(checkStatus, 100);
|
||||
}
|
||||
|
||||
function debugAuthentication() {
|
||||
console.log('=== AUTHENTICATION DEBUG ===');
|
||||
|
||||
// Check global storage-based authentication state (SINGLE SOURCE OF TRUTH)
|
||||
const authState = window.NOSTR_LOGIN_LITE.getAuthState();
|
||||
console.log('🔍 GLOBAL getAuthState():', authState);
|
||||
console.log('🔍 Derived isAuthenticated():', !!authState);
|
||||
console.log('🔍 Derived getUserInfo():', authState);
|
||||
|
||||
// Check window.nostr (should sync with global state)
|
||||
console.log('window.nostr exists:', !!window.nostr);
|
||||
console.log('window.nostr constructor:', window.nostr?.constructor?.name);
|
||||
console.log('window.nostr.authState (getter):', window.nostr?.authState);
|
||||
|
||||
// Check NOSTR_LOGIN_LITE instance
|
||||
const instance = window.NOSTR_LOGIN_LITE?._instance;
|
||||
console.log('NOSTR_LOGIN_LITE instance exists:', !!instance);
|
||||
console.log('Instance hasExtension:', instance?.hasExtension);
|
||||
console.log('Instance facadeInstalled:', instance?.facadeInstalled);
|
||||
|
||||
// Check floating tab state (now queries global getAuthState() only)
|
||||
const floatingTab = instance?.floatingTab;
|
||||
console.log('FloatingTab exists:', !!floatingTab);
|
||||
if (floatingTab) {
|
||||
const tabAuthState = floatingTab._getAuthState();
|
||||
console.log('FloatingTab _getAuthState():', tabAuthState);
|
||||
console.log('FloatingTab derived authenticated:', !!tabAuthState);
|
||||
}
|
||||
|
||||
// Check session storage directly
|
||||
const sessionKeys = [];
|
||||
const storageKey = 'nostr_login_lite_auth';
|
||||
const sessionAuthData = sessionStorage.getItem(storageKey);
|
||||
const localAuthData = localStorage.getItem(storageKey);
|
||||
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
if (key && key.startsWith('nl_')) {
|
||||
const value = sessionStorage.getItem(key);
|
||||
sessionKeys.push({ key, valueLength: value?.length || 0, hasValue: !!value });
|
||||
}
|
||||
}
|
||||
console.log('SessionStorage nl_ keys:', sessionKeys);
|
||||
console.log('SessionStorage auth data:', !!sessionAuthData);
|
||||
console.log('LocalStorage auth data:', !!localAuthData);
|
||||
|
||||
// Display debug results
|
||||
let debugHTML = '<h4>🔍 Storage-Based Authentication Debug</h4>';
|
||||
debugHTML += '<div style="font-family: monospace; font-size: 12px; background: #f8f9fa; padding: 10px; border-radius: 4px;">';
|
||||
debugHTML += `<strong>🎯 GLOBAL getAuthState():</strong> ${!!authState} ${authState ? `(${authState.method})` : ''}<br>`;
|
||||
debugHTML += `<strong>🎯 Derived isAuthenticated():</strong> ${!!authState}<br>`;
|
||||
debugHTML += `<strong>🎯 Derived getUserInfo():</strong> ${!!authState}<br>`;
|
||||
debugHTML += `<strong>window.nostr exists:</strong> ${!!window.nostr} (${window.nostr?.constructor?.name})<br>`;
|
||||
debugHTML += `<strong>window.nostr.authState (getter):</strong> ${!!window.nostr?.authState}<br>`;
|
||||
debugHTML += `<strong>FloatingTab queries getAuthState():</strong> ${!!floatingTab?._getAuthState()}<br>`;
|
||||
debugHTML += `<strong>SessionStorage 'nostr_login_lite_auth':</strong> ${!!sessionAuthData}<br>`;
|
||||
debugHTML += `<strong>LocalStorage 'nostr_login_lite_auth':</strong> ${!!localAuthData}<br>`;
|
||||
debugHTML += `<strong>Session storage nl_ keys:</strong> ${sessionKeys.length}<br>`;
|
||||
debugHTML += `<strong>Instance hasExtension:</strong> ${instance?.hasExtension}<br>`;
|
||||
debugHTML += `<strong>Facade installed:</strong> ${instance?.facadeInstalled}<br>`;
|
||||
debugHTML += '</div>';
|
||||
debugHTML += '<p><strong>Check the browser console for detailed debug output.</strong></p>';
|
||||
debugHTML += '<p><strong>NEW Architecture:</strong> Global functions query localStorage/sessionStorage directly as single source of truth</p>';
|
||||
|
||||
// Check for consistency issues
|
||||
const derivedAuth = !!authState;
|
||||
const floatingTabAuth = !!floatingTab?._getAuthState();
|
||||
|
||||
if (floatingTabAuth !== derivedAuth) {
|
||||
debugHTML += '<p style="color: red;"><strong>🚨 MISMATCH DETECTED:</strong> FloatingTab and global getAuthState() disagree!</p>';
|
||||
debugHTML += '<p>Both should query the same storage - check implementation.</p>';
|
||||
} else if (sessionAuthData && !derivedAuth) {
|
||||
debugHTML += '<p style="color: orange;"><strong>⚠️ PARSING ISSUE:</strong> Session data exists but getAuthState() returns null!</p>';
|
||||
debugHTML += '<p>Check getAuthState() function - it may not be parsing the stored data correctly.</p>';
|
||||
} else if (!sessionAuthData && !localAuthData && derivedAuth) {
|
||||
debugHTML += '<p style="color: orange;"><strong>⚠️ STORAGE ISSUE:</strong> No storage data but getAuthState() returns data!</p>';
|
||||
debugHTML += '<p>getAuthState() may be reading from unexpected sources.</p>';
|
||||
}
|
||||
|
||||
document.getElementById('results').innerHTML = debugHTML;
|
||||
}
|
||||
|
||||
async function checkStatus() {
|
||||
try {
|
||||
console.log('🔍 Checking authentication status using GLOBAL functions...');
|
||||
|
||||
// Use the single global storage-based authentication state function
|
||||
const authState = window.NOSTR_LOGIN_LITE.getAuthState();
|
||||
|
||||
console.log('🔍 GLOBAL getAuthState():', authState);
|
||||
console.log('🔍 Derived isAuthenticated():', !!authState);
|
||||
console.log('🔍 Derived getUserInfo():', authState);
|
||||
console.log('🔍 window.nostr:', !!window.nostr);
|
||||
console.log('🔍 window.nostr.constructor:', window.nostr?.constructor?.name);
|
||||
|
||||
// Check storage directly for debugging
|
||||
const storageKey = 'nostr_login_lite_auth';
|
||||
const sessionAuthData = sessionStorage.getItem(storageKey);
|
||||
const localAuthData = localStorage.getItem(storageKey);
|
||||
console.log('🔍 sessionStorage auth data:', !!sessionAuthData);
|
||||
console.log('🔍 localStorage auth data:', !!localAuthData);
|
||||
|
||||
if (authState) {
|
||||
let pubkey = null;
|
||||
try {
|
||||
if (window.nostr) {
|
||||
pubkey = await window.nostr.getPublicKey();
|
||||
} else if (authState.pubkey) {
|
||||
pubkey = authState.pubkey;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Could not get pubkey:', err.message);
|
||||
pubkey = authState.pubkey;
|
||||
}
|
||||
|
||||
const method = authState.method;
|
||||
|
||||
console.log('✅ Authentication detected via GLOBAL functions - method:', method, 'pubkey:', pubkey?.slice(0, 8) + '...');
|
||||
|
||||
document.getElementById('auth-status').innerHTML =
|
||||
`<strong style="color: green;">✅ Authenticated (Session Isolated)</strong>`;
|
||||
document.getElementById('auth-details').innerHTML =
|
||||
`<strong>Method:</strong> ${method}<br>
|
||||
<strong>Public Key:</strong> ${pubkey ? `${pubkey.slice(0, 16)}...${pubkey.slice(-8)}` : 'Available in authState'}<br>
|
||||
<strong>Storage:</strong> ${sessionAuthData ? 'sessionStorage' : 'localStorage'} (${sessionAuthData ? 'isolated to this tab' : 'shared across tabs'})<br>
|
||||
<strong>Persistence:</strong> Survives refresh${sessionAuthData ? ', isolated from other tabs' : ', shared with other tabs'}<br>
|
||||
<strong>Debug:</strong> Global getAuthState() returns valid data`;
|
||||
} else if (sessionAuthData || localAuthData) {
|
||||
// We have storage data but getAuthState() returns null
|
||||
console.log('⚠️ Storage data exists but getAuthState() returns null');
|
||||
|
||||
document.getElementById('auth-status').innerHTML =
|
||||
`<strong style="color: orange;">⚠️ Authentication data found but not parsed</strong>`;
|
||||
document.getElementById('auth-details').innerHTML =
|
||||
`<strong>Storage:</strong> ${sessionAuthData ? 'sessionStorage' : 'localStorage'} has authentication data<br>
|
||||
<strong>Issue:</strong> getAuthState() returns null<br>
|
||||
<strong>Debug:</strong> Storage data: session=${!!sessionAuthData}, local=${!!localAuthData}<br>
|
||||
<strong>Solution:</strong> Check getAuthState() function implementation`;
|
||||
} else {
|
||||
console.log('❌ No authentication detected via getAuthState()');
|
||||
|
||||
document.getElementById('auth-status').innerHTML =
|
||||
`<strong style="color: red;">❌ Not authenticated</strong>`;
|
||||
document.getElementById('auth-details').innerHTML =
|
||||
`<strong>Storage:</strong> sessionStorage (isolated to this tab)<br>
|
||||
<strong>Status:</strong> Ready for login - will persist on refresh<br>
|
||||
<strong>Debug:</strong> getAuthState() returns no authentication data`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error checking status:', error);
|
||||
|
||||
document.getElementById('auth-status').innerHTML =
|
||||
`<strong style="color: orange;">⚠️ Error checking status</strong>`;
|
||||
document.getElementById('auth-details').innerHTML =
|
||||
`Error: ${error.message}<br>
|
||||
<strong>Debug:</strong> Check browser console for details`;
|
||||
}
|
||||
}
|
||||
|
||||
async function testSigning() {
|
||||
try {
|
||||
// Use global authentication state to check if authenticated
|
||||
const authState = window.NOSTR_LOGIN_LITE.getAuthState();
|
||||
if (!authState) {
|
||||
throw new Error('Not authenticated (checked via global getAuthState())');
|
||||
}
|
||||
|
||||
if (!window.nostr) {
|
||||
throw new Error('window.nostr not available for signing');
|
||||
}
|
||||
|
||||
const event = {
|
||||
kind: 1,
|
||||
content: `Test message from ISOLATED session - ${new Date().toISOString()}`,
|
||||
tags: [],
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
|
||||
document.getElementById('results').innerHTML =
|
||||
`<h4>✅ Signing Test Successful (Session Isolated)</h4>
|
||||
<p>This signature was created using the storage-based authentication system.</p>
|
||||
<p><strong>Authentication Method:</strong> getAuthState() confirmed authentication before signing</p>
|
||||
<pre>${JSON.stringify(signedEvent, null, 2)}</pre>`;
|
||||
|
||||
} catch (error) {
|
||||
document.getElementById('results').innerHTML =
|
||||
`<h4>❌ Signing Test Failed</h4>
|
||||
<p style="color: red;">${error.message}</p>
|
||||
<p><strong>Debug Info:</strong></p>
|
||||
<ul>
|
||||
<li>getAuthState(): ${!!window.NOSTR_LOGIN_LITE.getAuthState()}</li>
|
||||
<li>window.nostr exists: ${!!window.nostr}</li>
|
||||
<li>Auth method: ${JSON.stringify(window.NOSTR_LOGIN_LITE.getAuthState()?.method || null)}</li>
|
||||
</ul>`;
|
||||
}
|
||||
}
|
||||
|
||||
function openNewWindow() {
|
||||
const newWindow = window.open(
|
||||
window.location.href,
|
||||
'_blank',
|
||||
'width=900,height=700,scrollbars=yes,resizable=yes'
|
||||
);
|
||||
|
||||
if (newWindow) {
|
||||
document.getElementById('results').innerHTML =
|
||||
`<h4>🪟 New Window Opened - Independent Session</h4>
|
||||
<p><strong>Session Isolation Test:</strong></p>
|
||||
<ol>
|
||||
<li>The new window starts unauthenticated (independent session)</li>
|
||||
<li>Login in the new window with a different method or user</li>
|
||||
<li>Both windows maintain separate authentication states</li>
|
||||
<li>Refresh either window - authentication persists within that window only</li>
|
||||
<li>Close a window - its authentication is lost (sessionStorage cleared)</li>
|
||||
</ol>
|
||||
<p><strong>Expected Behavior:</strong> Each window/tab has completely independent authentication that persists on refresh but doesn't leak to other windows.</p>`;
|
||||
} else {
|
||||
document.getElementById('results').innerHTML =
|
||||
`<h4>❌ Failed to Open Window</h4>
|
||||
<p>Please allow popups and try again</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function inspectStorage() {
|
||||
const sessionStorage_keys = [];
|
||||
|
||||
// Inspect sessionStorage (our isolated storage)
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
if (key && key.startsWith('nl_')) {
|
||||
sessionStorage_keys.push({
|
||||
key,
|
||||
value: sessionStorage.getItem(key)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let content = '<h4>📊 Session Storage Inspection</h4>';
|
||||
content += '<p><strong>Note:</strong> This tab uses sessionStorage for isolation - data here is independent of other tabs/windows.</p>';
|
||||
|
||||
content += '<h5>sessionStorage (This tab only):</h5>';
|
||||
if (sessionStorage_keys.length === 0) {
|
||||
content += '<p style="color: #666;">No authentication data found in this session</p>';
|
||||
} else {
|
||||
content += '<p style="color: green;">✅ Authentication data found (persists on refresh)</p>';
|
||||
content += '<pre>' + JSON.stringify(sessionStorage_keys, null, 2) + '</pre>';
|
||||
}
|
||||
|
||||
// Show what would be in localStorage if we weren't using isolation
|
||||
const localStorage_keys = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith('nl_')) {
|
||||
localStorage_keys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
content += '<h5>localStorage (Not used in isolated mode):</h5>';
|
||||
if (localStorage_keys.length === 0) {
|
||||
content += '<p style="color: #666;">No NOSTR_LOGIN_LITE data (expected in isolated mode)</p>';
|
||||
} else {
|
||||
content += '<p style="color: orange;">⚠️ Found some data - might be from non-isolated sessions</p>';
|
||||
}
|
||||
|
||||
document.getElementById('storage-content').innerHTML = content;
|
||||
}
|
||||
|
||||
function clearStorage() {
|
||||
// Clear only sessionStorage (our isolated storage)
|
||||
const sessionKeys = [];
|
||||
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
if (key && key.startsWith('nl_')) {
|
||||
sessionKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
sessionKeys.forEach(key => sessionStorage.removeItem(key));
|
||||
|
||||
document.getElementById('storage-content').innerHTML =
|
||||
`<h4>🧹 Session Storage Cleared</h4>
|
||||
<p>Removed ${sessionKeys.length} authentication items from this tab's sessionStorage</p>
|
||||
<p><strong>Result:</strong> This tab is now logged out, but other tabs are unaffected</p>`;
|
||||
|
||||
// Update status
|
||||
setTimeout(checkStatus, 100);
|
||||
}
|
||||
|
||||
// Listen for authentication events
|
||||
window.addEventListener('nlMethodSelected', (event) => {
|
||||
console.log('Authentication successful in isolated session:', event.detail);
|
||||
setTimeout(checkStatus, 100);
|
||||
|
||||
document.getElementById('results').innerHTML =
|
||||
`<h4>✅ Authentication Successful (Session Isolated)</h4>
|
||||
<p><strong>Method:</strong> ${event.detail.method}</p>
|
||||
<p><strong>Storage:</strong> sessionStorage (isolated to this tab)</p>
|
||||
<p><strong>Persistence:</strong> Will survive refresh, won't affect other tabs</p>
|
||||
<p><strong>Test:</strong> Open a new tab - it should start unauthenticated</p>`;
|
||||
});
|
||||
|
||||
window.addEventListener('nlLogout', (event) => {
|
||||
console.log('Logout detected in isolated session:', event.detail);
|
||||
setTimeout(checkStatus, 100);
|
||||
|
||||
document.getElementById('results').innerHTML =
|
||||
`<h4>👋 Logged Out (Session Isolated)</h4>
|
||||
<p>Authentication cleared from this tab's sessionStorage only</p>
|
||||
<p><strong>Result:</strong> Other tabs remain unaffected by this logout</p>`;
|
||||
});
|
||||
|
||||
// Check status on page load (should restore from sessionStorage if available)
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(checkStatus, 500);
|
||||
|
||||
// Show persistence message if we're restoring authentication
|
||||
if (sessionStorage.getItem('nl_auth_state') || sessionStorage.getItem('nl_current')) {
|
||||
setTimeout(() => {
|
||||
document.getElementById('results').innerHTML =
|
||||
`<h4>🔄 Session Restored</h4>
|
||||
<p>Authentication state restored from sessionStorage on page load</p>
|
||||
<p><strong>Isolation confirmed:</strong> This tab's login state is independent</p>`;
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,184 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NIP-07 Signing Test</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div>
|
||||
<div id="status"></div>
|
||||
|
||||
<div id="test-section" style="display:none;">
|
||||
<button id="sign-button">Sign Event</button>
|
||||
<button id="encrypt-button">Test NIP-04 Encrypt</button>
|
||||
<button id="decrypt-button">Test NIP-04 Decrypt</button>
|
||||
<div id="results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="../lite/nostr.bundle.js"></script>
|
||||
<script src="../lite/nostr-lite.js"></script>
|
||||
|
||||
<script>
|
||||
let testPubkey = 'npub1damus9dqe7g7jqn45kjcjgsv0vxjqnk8cxjkf8gqjwm8t8qjm7cqm3z7l';
|
||||
let ciphertext = '';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await window.NOSTR_LOGIN_LITE.init({
|
||||
theme: 'default',
|
||||
methods: {
|
||||
extension: true,
|
||||
local: true,
|
||||
readonly: true,
|
||||
connect: true,
|
||||
remote: true,
|
||||
otp: true
|
||||
},
|
||||
floatingTab: {
|
||||
enabled: true,
|
||||
hPosition: 1, // 0.0-1.0 or '95%' from left
|
||||
vPosition: 0, // 0.0-1.0 or '50%' from top
|
||||
appearance: {
|
||||
style: 'pill', // 'pill', 'square', 'circle', 'minimal'
|
||||
icon: '', // Clean display without icon placeholders
|
||||
text: 'Login'
|
||||
},
|
||||
behavior: {
|
||||
hideWhenAuthenticated: false,
|
||||
showUserInfo: true,
|
||||
autoSlide: true
|
||||
},
|
||||
getUserInfo: true, // Enable profile fetching
|
||||
getUserRelay: [ // Specific relays for profile fetching
|
||||
'wss://relay.laantungir.net'
|
||||
]
|
||||
}});
|
||||
|
||||
|
||||
// document.getElementById('login-button').addEventListener('click', () => {
|
||||
// window.NOSTR_LOGIN_LITE.launch('login');
|
||||
// });
|
||||
|
||||
window.addEventListener('nlMethodSelected', (event) => {
|
||||
document.getElementById('status').textContent = `Authenticated with: ${event.detail.method}`;
|
||||
document.getElementById('test-section').style.display = 'block';
|
||||
});
|
||||
|
||||
document.getElementById('sign-button').addEventListener('click', testSigning);
|
||||
document.getElementById('encrypt-button').addEventListener('click', testEncryption);
|
||||
document.getElementById('decrypt-button').addEventListener('click', testDecryption);
|
||||
});
|
||||
|
||||
async function testSigning() {
|
||||
try {
|
||||
console.log('=== DEBUGGING SIGN EVENT START ===');
|
||||
console.log('testSigning: window.nostr is:', window.nostr);
|
||||
console.log('testSigning: window.nostr constructor:', window.nostr?.constructor?.name);
|
||||
console.log('testSigning: window.nostr === our facade?', window.nostr?.constructor?.name === 'WindowNostr');
|
||||
|
||||
// Get user public key for comparison
|
||||
const userPubkey = await window.nostr.getPublicKey();
|
||||
console.log('User public key:', userPubkey);
|
||||
|
||||
// Check auth state if our facade
|
||||
if (window.nostr?.constructor?.name === 'WindowNostr') {
|
||||
console.log('WindowNostr authState:', window.nostr.authState);
|
||||
console.log('WindowNostr authenticatedExtension:', window.nostr.authenticatedExtension);
|
||||
console.log('WindowNostr existingNostr:', window.nostr.existingNostr);
|
||||
}
|
||||
|
||||
const event = {
|
||||
kind: 1,
|
||||
content: 'Hello from NIP-07!',
|
||||
tags: [],
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
|
||||
console.log('=== EVENT BEING SENT TO EXTENSION ===');
|
||||
console.log('Event object:', JSON.stringify(event, null, 2));
|
||||
console.log('Event keys:', Object.keys(event));
|
||||
console.log('Event kind type:', typeof event.kind, event.kind);
|
||||
console.log('Event content type:', typeof event.content, event.content);
|
||||
console.log('Event tags type:', typeof event.tags, event.tags);
|
||||
console.log('Event created_at type:', typeof event.created_at, event.created_at);
|
||||
console.log('Event created_at value:', event.created_at);
|
||||
|
||||
// Check if created_at is within reasonable bounds
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const timeDiff = Math.abs(event.created_at - now);
|
||||
console.log('Time difference from now (seconds):', timeDiff);
|
||||
console.log('Event timestamp as Date:', new Date(event.created_at * 1000));
|
||||
|
||||
// Additional debugging for user-specific issues
|
||||
console.log('=== USER-SPECIFIC DEBUG INFO ===');
|
||||
console.log('User pubkey length:', userPubkey?.length);
|
||||
console.log('User pubkey format check (hex):', /^[a-fA-F0-9]{64}$/.test(userPubkey));
|
||||
|
||||
// Try to get user profile info if available
|
||||
try {
|
||||
const profileEvent = {
|
||||
kinds: [0],
|
||||
authors: [userPubkey],
|
||||
limit: 1
|
||||
};
|
||||
console.log('Would query profile with filter:', profileEvent);
|
||||
} catch (profileErr) {
|
||||
console.log('Profile query setup failed:', profileErr);
|
||||
}
|
||||
|
||||
console.log('=== ABOUT TO CALL EXTENSION SIGN EVENT ===');
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
|
||||
console.log('=== SIGN EVENT SUCCESSFUL ===');
|
||||
console.log('Signed event:', JSON.stringify(signedEvent, null, 2));
|
||||
console.log('Signed event keys:', Object.keys(signedEvent));
|
||||
console.log('Signature present:', !!signedEvent.sig);
|
||||
console.log('ID present:', !!signedEvent.id);
|
||||
console.log('Pubkey matches user:', signedEvent.pubkey === userPubkey);
|
||||
|
||||
document.getElementById('results').innerHTML = `<h3>Signed Event:</h3><pre>${JSON.stringify(signedEvent, null, 2)}</pre>`;
|
||||
|
||||
console.log('=== DEBUGGING SIGN EVENT END ===');
|
||||
} catch (error) {
|
||||
console.error('=== SIGN EVENT ERROR ===');
|
||||
console.error('Error message:', error.message);
|
||||
console.error('Error stack:', error.stack);
|
||||
console.error('Error object:', error);
|
||||
|
||||
document.getElementById('results').innerHTML = `<h3>Sign Error:</h3><pre>${error.message}</pre><pre>${error.stack}</pre>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function testEncryption() {
|
||||
try {
|
||||
const plaintext = 'Secret message for testing';
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
|
||||
ciphertext = await window.nostr.nip04.encrypt(pubkey, plaintext);
|
||||
document.getElementById('results').innerHTML = `<h3>Encrypted:</h3><pre>${ciphertext}</pre>`;
|
||||
} catch (error) {
|
||||
document.getElementById('results').innerHTML = `<h3>Encrypt Error:</h3><pre>${error.message}</pre>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function testDecryption() {
|
||||
try {
|
||||
if (!ciphertext) {
|
||||
document.getElementById('results').innerHTML = `<h3>Decrypt Error:</h3><pre>No ciphertext available. Run encrypt first.</pre>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
const decrypted = await window.nostr.nip04.decrypt(pubkey, ciphertext);
|
||||
document.getElementById('results').innerHTML = `<h3>Decrypted:</h3><pre>${decrypted}</pre>`;
|
||||
} catch (error) {
|
||||
document.getElementById('results').innerHTML = `<h3>Decrypt Error:</h3><pre>${error.message}</pre>`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/bin/bash
|
||||
|
||||
# increment_build_push.sh
|
||||
# Automates version increment, build, and git operations
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}🔄 Starting increment, build, and push process...${NC}"
|
||||
|
||||
# Function to get the latest git tag
|
||||
get_latest_tag() {
|
||||
# Get the latest tag that matches the pattern v*.*.*
|
||||
git tag -l "v*.*.*" | sort -V | tail -n1
|
||||
}
|
||||
|
||||
# Function to increment version
|
||||
increment_version() {
|
||||
local version=$1
|
||||
# Remove 'v' prefix if present
|
||||
version=${version#v}
|
||||
|
||||
# Split version into parts
|
||||
IFS='.' read -ra VERSION_PARTS <<< "$version"
|
||||
|
||||
# Increment the patch version (last digit)
|
||||
local major=${VERSION_PARTS[0]}
|
||||
local minor=${VERSION_PARTS[1]}
|
||||
local patch=${VERSION_PARTS[2]}
|
||||
|
||||
patch=$((patch + 1))
|
||||
|
||||
echo "$major.$minor.$patch"
|
||||
}
|
||||
|
||||
# Step 1: Get current version
|
||||
echo -e "${YELLOW}📋 Getting current version...${NC}"
|
||||
current_tag=$(get_latest_tag)
|
||||
if [ -z "$current_tag" ]; then
|
||||
echo -e "${YELLOW}⚠️ No existing version tags found, starting with v0.1.0${NC}"
|
||||
current_version="0.1.0"
|
||||
else
|
||||
echo -e "Current tag: ${current_tag}"
|
||||
current_version=${current_tag#v}
|
||||
fi
|
||||
|
||||
# Step 2: Increment version
|
||||
new_version=$(increment_version "$current_version")
|
||||
new_tag="v$new_version"
|
||||
|
||||
echo -e "${GREEN}📈 Incrementing version: $current_version → $new_version${NC}"
|
||||
|
||||
# Step 2.5: Save version to src/VERSION file
|
||||
echo -e "${YELLOW}💾 Saving version to src/VERSION...${NC}"
|
||||
echo "$new_version" > src/VERSION
|
||||
echo -e "Version saved: ${GREEN}$new_version${NC}"
|
||||
|
||||
# Step 2.5: Run build.js
|
||||
echo -e "${YELLOW}🔧 Running build process...${NC}"
|
||||
cd src
|
||||
node build.js
|
||||
cd ..
|
||||
echo -e "${GREEN}✅ Build completed${NC}"
|
||||
|
||||
# Step 3: Git add
|
||||
echo -e "${YELLOW}📦 Adding files to git...${NC}"
|
||||
git add .
|
||||
|
||||
# Step 4: Handle commit message and commit
|
||||
commit_message=""
|
||||
if [ $# -eq 0 ]; then
|
||||
# No arguments provided, ask for commit message
|
||||
echo -e "${YELLOW}💬 Please enter a commit message:${NC}"
|
||||
read -p "> " commit_message
|
||||
|
||||
if [ -z "$commit_message" ]; then
|
||||
echo -e "${RED}❌ Commit message cannot be empty${NC}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Use provided arguments as commit message
|
||||
commit_message="$*"
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}💬 Committing changes...${NC}"
|
||||
git commit -m "$commit_message"
|
||||
|
||||
echo -e "${YELLOW}🏷️ Creating git tag: $new_tag${NC}"
|
||||
git tag "$new_tag"
|
||||
|
||||
# Step 5: Git push
|
||||
echo -e "${YELLOW}🚀 Pushing to remote...${NC}"
|
||||
git push
|
||||
git push --tags
|
||||
|
||||
echo -e "${GREEN}🎉 Successfully completed:${NC}"
|
||||
echo -e " • Version incremented to: ${GREEN}$new_version${NC}"
|
||||
echo -e " • VERSION file updated: ${GREEN}src/VERSION${NC}"
|
||||
echo -e " • Build completed: ${GREEN}build/nostr-lite.js${NC}"
|
||||
echo -e " • Git tag created: ${GREEN}$new_tag${NC}"
|
||||
echo -e " • Changes pushed to remote${NC}"
|
||||
echo -e "\n${GREEN}✨ Process complete!${NC}"
|
||||
-315
@@ -1,315 +0,0 @@
|
||||
/**
|
||||
* Simple script to create NOSTR_LOGIN_LITE bundle
|
||||
* For the new two-file architecture:
|
||||
* 1. nostr.bundle.js (official nostr-tools bundle - static file)
|
||||
* 2. nip46-extension.js (NIP-46 extension - static file)
|
||||
* 3. nostr-lite.js (NOSTR_LOGIN_LITE library - built by this script)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function createNostrLoginLiteBundle() {
|
||||
console.log('🔧 Creating NOSTR_LOGIN_LITE bundle for two-file architecture...');
|
||||
|
||||
const outputPath = path.join(__dirname, 'nostr-lite.js');
|
||||
|
||||
// Remove old bundle
|
||||
try {
|
||||
if (fs.existsSync(outputPath)) {
|
||||
fs.unlinkSync(outputPath);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('No old bundle to remove');
|
||||
}
|
||||
|
||||
// Start with the bundle header
|
||||
let bundle = `/**
|
||||
* NOSTR_LOGIN_LITE - Authentication Library
|
||||
* Two-file architecture:
|
||||
* 1. Load nostr.bundle.js (official nostr-tools bundle)
|
||||
* 2. Load nip46-extension.js (extends NostrTools with NIP-46)
|
||||
* 3. Load nostr-lite.js (this file - NOSTR_LOGIN_LITE library)
|
||||
* Generated on: ${new Date().toISOString()}
|
||||
*/
|
||||
|
||||
// Verify dependencies are loaded
|
||||
if (typeof window !== 'undefined') {
|
||||
if (!window.NostrTools) {
|
||||
console.error('NOSTR_LOGIN_LITE: nostr.bundle.js must be loaded first');
|
||||
throw new Error('Missing dependency: nostr.bundle.js');
|
||||
}
|
||||
|
||||
if (!window.NostrTools.nip46) {
|
||||
console.error('NOSTR_LOGIN_LITE: nip46-extension.js must be loaded after nostr.bundle.js');
|
||||
throw new Error('Missing dependency: nip46-extension.js');
|
||||
}
|
||||
|
||||
console.log('NOSTR_LOGIN_LITE: Dependencies verified ✓');
|
||||
console.log('NOSTR_LOGIN_LITE: NostrTools available with keys:', Object.keys(window.NostrTools));
|
||||
console.log('NOSTR_LOGIN_LITE: NIP-46 available:', !!window.NostrTools.nip46);
|
||||
}
|
||||
|
||||
// ======================================
|
||||
// NOSTR_LOGIN_LITE Components
|
||||
// ======================================
|
||||
|
||||
`;
|
||||
|
||||
// Add Modal UI
|
||||
const modalPath = path.join(__dirname, 'ui/modal.js');
|
||||
if (fs.existsSync(modalPath)) {
|
||||
console.log('📄 Adding Modal UI...');
|
||||
|
||||
let modalContent = fs.readFileSync(modalPath, 'utf8');
|
||||
|
||||
// Skip header comments
|
||||
let lines = modalContent.split('\n');
|
||||
let contentStartIndex = 0;
|
||||
|
||||
for (let i = 0; i < Math.min(15, lines.length); i++) {
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('/**') || line.startsWith('*') ||
|
||||
line.startsWith('/*') || line.startsWith('//')) {
|
||||
contentStartIndex = i + 1;
|
||||
} else if (line && !line.startsWith('*') && !line.startsWith('//')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (contentStartIndex > 0) {
|
||||
lines = lines.slice(contentStartIndex);
|
||||
}
|
||||
|
||||
bundle += `// ======================================\n`;
|
||||
bundle += `// Modal UI Component\n`;
|
||||
bundle += `// ======================================\n\n`;
|
||||
bundle += lines.join('\n');
|
||||
bundle += '\n\n';
|
||||
} else {
|
||||
console.warn('⚠️ Modal UI not found: ui/modal.js');
|
||||
}
|
||||
|
||||
// Add main library code
|
||||
console.log('📄 Adding Main Library...');
|
||||
bundle += `
|
||||
// ======================================
|
||||
// Main NOSTR_LOGIN_LITE Library
|
||||
// ======================================
|
||||
|
||||
// Extension Bridge for managing browser extensions
|
||||
class ExtensionBridge {
|
||||
constructor() {
|
||||
this.extensions = new Map();
|
||||
this.primaryExtension = null;
|
||||
this._detectExtensions();
|
||||
}
|
||||
|
||||
_detectExtensions() {
|
||||
// Common extension locations
|
||||
const locations = [
|
||||
{ path: 'window.nostr', name: 'Generic' },
|
||||
{ path: 'window.alby?.nostr', name: 'Alby' },
|
||||
{ path: 'window.nos2x?.nostr', name: 'nos2x' },
|
||||
{ path: 'window.flamingo?.nostr', name: 'Flamingo' },
|
||||
{ path: 'window.getAlby?.nostr', name: 'Alby Legacy' },
|
||||
{ path: 'window.mutiny?.nostr', name: 'Mutiny' }
|
||||
];
|
||||
|
||||
for (const location of locations) {
|
||||
try {
|
||||
const obj = eval(location.path);
|
||||
if (obj && typeof obj.getPublicKey === 'function') {
|
||||
this.extensions.set(location.name, {
|
||||
name: location.name,
|
||||
extension: obj,
|
||||
constructor: obj.constructor?.name || 'Unknown'
|
||||
});
|
||||
|
||||
if (!this.primaryExtension) {
|
||||
this.primaryExtension = this.extensions.get(location.name);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Extension not available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getAllExtensions() {
|
||||
return Array.from(this.extensions.values());
|
||||
}
|
||||
|
||||
getExtensionCount() {
|
||||
return this.extensions.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Main NostrLite class
|
||||
class NostrLite {
|
||||
constructor() {
|
||||
this.options = {};
|
||||
this.extensionBridge = new ExtensionBridge();
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
async init(options = {}) {
|
||||
console.log('NOSTR_LOGIN_LITE: Initializing with options:', options);
|
||||
|
||||
this.options = {
|
||||
theme: 'light',
|
||||
darkMode: false,
|
||||
relays: ['wss://relay.damus.io', 'wss://nos.lol'],
|
||||
methods: {
|
||||
extension: true,
|
||||
local: true,
|
||||
readonly: true,
|
||||
connect: false,
|
||||
otp: false
|
||||
},
|
||||
...options
|
||||
};
|
||||
|
||||
// Set up window.nostr facade if no extension detected
|
||||
if (this.extensionBridge.getExtensionCount() === 0) {
|
||||
this._setupWindowNostrFacade();
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
console.log('NOSTR_LOGIN_LITE: Initialization complete');
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
_setupWindowNostrFacade() {
|
||||
if (typeof window !== 'undefined' && !window.nostr) {
|
||||
window.nostr = new WindowNostr(this);
|
||||
console.log('NOSTR_LOGIN_LITE: window.nostr facade installed');
|
||||
}
|
||||
}
|
||||
|
||||
launch(startScreen = 'login') {
|
||||
console.log('NOSTR_LOGIN_LITE: Launching with screen:', startScreen);
|
||||
|
||||
if (typeof Modal !== 'undefined') {
|
||||
const modal = new Modal(this.options);
|
||||
modal.open({ startScreen });
|
||||
} else {
|
||||
console.error('NOSTR_LOGIN_LITE: Modal component not available');
|
||||
}
|
||||
}
|
||||
|
||||
logout() {
|
||||
console.log('NOSTR_LOGIN_LITE: Logout called');
|
||||
|
||||
// Clear stored data
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem('nl_current');
|
||||
}
|
||||
|
||||
// Dispatch logout event
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('nlLogout', {
|
||||
detail: { timestamp: Date.now() }
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Window.nostr facade for when no extension is available
|
||||
class WindowNostr {
|
||||
constructor(nostrLite) {
|
||||
this.nostrLite = nostrLite;
|
||||
}
|
||||
|
||||
async getPublicKey() {
|
||||
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
|
||||
}
|
||||
|
||||
async signEvent(event) {
|
||||
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
|
||||
}
|
||||
|
||||
async getRelays() {
|
||||
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
|
||||
}
|
||||
|
||||
get nip04() {
|
||||
return {
|
||||
async encrypt(pubkey, plaintext) {
|
||||
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
|
||||
},
|
||||
async decrypt(pubkey, ciphertext) {
|
||||
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
get nip44() {
|
||||
return {
|
||||
async encrypt(pubkey, plaintext) {
|
||||
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
|
||||
},
|
||||
async decrypt(pubkey, ciphertext) {
|
||||
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize and export
|
||||
if (typeof window !== 'undefined') {
|
||||
const nostrLite = new NostrLite();
|
||||
|
||||
// Export main API
|
||||
window.NOSTR_LOGIN_LITE = {
|
||||
init: (options) => nostrLite.init(options),
|
||||
launch: (startScreen) => nostrLite.launch(startScreen),
|
||||
logout: () => nostrLite.logout(),
|
||||
|
||||
// Expose for debugging
|
||||
_extensionBridge: nostrLite.extensionBridge,
|
||||
_instance: nostrLite
|
||||
};
|
||||
|
||||
console.log('NOSTR_LOGIN_LITE: Library loaded and ready');
|
||||
console.log('NOSTR_LOGIN_LITE: Use window.NOSTR_LOGIN_LITE.init(options) to initialize');
|
||||
console.log('NOSTR_LOGIN_LITE: Detected', nostrLite.extensionBridge.getExtensionCount(), 'browser extensions');
|
||||
} else {
|
||||
// Node.js environment
|
||||
module.exports = { NostrLite };
|
||||
}
|
||||
`;
|
||||
|
||||
// Write the complete bundle
|
||||
fs.writeFileSync(outputPath, bundle, 'utf8');
|
||||
|
||||
const sizeKB = (bundle.length / 1024).toFixed(2);
|
||||
console.log(`\n✅ nostr-lite.js bundle created: ${outputPath}`);
|
||||
console.log(`📏 Bundle size: ${sizeKB} KB`);
|
||||
console.log(`📄 Total lines: ${bundle.split('\n').length}`);
|
||||
|
||||
// Check what's included
|
||||
const hasModal = bundle.includes('class Modal');
|
||||
const hasNostrLite = bundle.includes('NOSTR_LOGIN_LITE');
|
||||
|
||||
console.log('\n📋 Bundle contents:');
|
||||
console.log(` Modal UI: ${hasModal ? '✅ Included' : '❌ Missing'}`);
|
||||
console.log(` NOSTR_LOGIN_LITE: ${hasNostrLite ? '✅ Included' : '❌ Missing'}`);
|
||||
console.log(` Extension Bridge: ✅ Included`);
|
||||
console.log(` Window.nostr facade: ✅ Included`);
|
||||
|
||||
console.log('\n📋 Two-file architecture:');
|
||||
console.log(' 1. nostr.bundle.js (official nostr-tools - 220KB)');
|
||||
console.log(' 2. nip46-extension.js (NIP-46 support - ~15KB)');
|
||||
console.log(` 3. nostr-lite.js (NOSTR_LOGIN_LITE - ${sizeKB}KB)`);
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (typeof require !== 'undefined' && require.main === module) {
|
||||
createNostrLoginLiteBundle();
|
||||
}
|
||||
|
||||
module.exports = { createNostrLoginLiteBundle };
|
||||
-2379
File diff suppressed because it is too large
Load Diff
-1082
File diff suppressed because it is too large
Load Diff
+413
@@ -0,0 +1,413 @@
|
||||
# NOSTR_LOGIN_LITE - Login Logic Analysis
|
||||
|
||||
This document explains the complete login and authentication flow for the NOSTR_LOGIN_LITE library, including how state is maintained upon page refresh.
|
||||
|
||||
## System Architecture Overview
|
||||
|
||||
The library uses a **modular authentication architecture** with these key components:
|
||||
|
||||
1. **FloatingTab** - UI component for login trigger and status display
|
||||
2. **Modal** - UI component for authentication method selection
|
||||
3. **NostrLite** - Main library coordinator and facade manager
|
||||
4. **WindowNostr** - NIP-07 compliant facade for non-extension methods
|
||||
5. **AuthManager** - Persistent state management with encryption
|
||||
6. **Extension Bridge** - Browser extension detection and management
|
||||
|
||||
## Authentication Flow Diagrams
|
||||
|
||||
### Initial Page Load Flow
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Page Loads │
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ NOSTR_LOGIN_LITE │
|
||||
│ .init() called │
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐ YES ┌─────────────────────┐
|
||||
│ Real extension │──────────▶│ Extension-First │
|
||||
│ detected? │ │ Mode: Don't install │
|
||||
└─────────┬───────────┘ │ facade │
|
||||
│ NO └─────────────────────┘
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Install WindowNostr │
|
||||
│ facade for local/ │
|
||||
│ NIP-46/readonly │
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐ YES ┌─────────────────────┐
|
||||
│ Persistence │──────────▶│ _attemptAuthRestore │
|
||||
│ enabled? │ │ called │
|
||||
└─────────┬───────────┘ └─────────┬───────────┘
|
||||
│ NO │
|
||||
▼ ▼
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ Initialization │ │ Check storage for │
|
||||
│ complete │ │ saved auth state │
|
||||
└─────────────────────┘ └─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐ YES
|
||||
│ Valid auth state │────────┐
|
||||
│ found? │ │
|
||||
└─────────┬───────────┘ │
|
||||
│ NO │
|
||||
▼ ▼
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ Show login UI │ │ Restore auth & │
|
||||
│ (FloatingTab,etc) │ │ dispatch events │
|
||||
└─────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
### User-Initiated Login Flow
|
||||
|
||||
```
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ User clicks │ │ User clicks │
|
||||
│ FloatingTab │ │ Login Button │
|
||||
└─────────┬───────────┘ └─────────┬───────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────┐ │
|
||||
│ Extension │ │
|
||||
│ available? │ │
|
||||
└─────────┬───────────┘ │
|
||||
│ YES │
|
||||
▼ │
|
||||
┌─────────────────────┐ │
|
||||
│ Auto-try extension │ │
|
||||
│ authentication │ │
|
||||
└─────────┬───────────┘ │
|
||||
│ SUCCESS │
|
||||
▼ │
|
||||
┌─────────────────────┐ │
|
||||
│ Authentication │ │
|
||||
│ complete │◀──────────────────┘
|
||||
└─────────────────────┘ │ FAIL OR ALWAYS
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Open Modal with │
|
||||
│ method selection: │
|
||||
│ • Extension │
|
||||
│ • Local Key │
|
||||
│ • NIP-46 Connect │
|
||||
│ • Read-only │
|
||||
│ • OTP/DM │
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ User selects method │
|
||||
│ and completes auth │
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Authentication │
|
||||
│ complete │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### Authentication Storage & Persistence Flow
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Authentication │
|
||||
│ successful │
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ nlMethodSelected │
|
||||
│ event dispatched │
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐ Extension? ┌─────────────────────┐
|
||||
│ AuthManager. │─────────────────▶│ Store verification │
|
||||
│ saveAuthState() │ │ data only (no │
|
||||
└─────────┬───────────┘ │ secrets) │
|
||||
│ Local Key? └─────────────────────┘
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Encrypt secret key │
|
||||
│ with session │
|
||||
│ password + AES-GCM │
|
||||
└─────────┬───────────┘
|
||||
│ NIP-46?
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Store connection │
|
||||
│ parameters (no │
|
||||
│ secrets) │
|
||||
└─────────┬───────────┘
|
||||
│ Read-only?
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Store method only │
|
||||
│ (no secrets) │
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐ isolateSession? ┌─────────────────────┐
|
||||
│ Choose storage: │─────────YES─────────▶│ sessionStorage │
|
||||
│ localStorage vs │ │ (per-window) │
|
||||
│ sessionStorage │◀────────NO───────────┤ │
|
||||
└─────────┬───────────┘ └─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ localStorage │
|
||||
│ (cross-window) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
## Key Decision Points and Logic
|
||||
|
||||
### 1. Extension Detection Logic (Line 994-1046)
|
||||
|
||||
**Function:** `NostrLite._isRealExtension(obj)`
|
||||
|
||||
```javascript
|
||||
// Conservative extension detection
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
if (typeof obj.getPublicKey !== 'function' || typeof obj.signEvent !== 'function') return false;
|
||||
|
||||
// Exclude our own classes
|
||||
const constructorName = obj.constructor?.name;
|
||||
if (constructorName === 'WindowNostr' || constructorName === 'NostrLite') return false;
|
||||
if (obj === window.NostrTools) return false;
|
||||
|
||||
// Look for extension indicators
|
||||
const extensionIndicators = [
|
||||
'_isEnabled', 'enabled', 'kind', '_eventEmitter', '_scope',
|
||||
'_requests', '_pubkey', 'name', 'version', 'description'
|
||||
];
|
||||
const hasIndicators = extensionIndicators.some(prop => obj.hasOwnProperty(prop));
|
||||
const hasExtensionConstructor = constructorName &&
|
||||
constructorName !== 'Object' &&
|
||||
constructorName !== 'Function';
|
||||
|
||||
return hasIndicators || hasExtensionConstructor;
|
||||
```
|
||||
|
||||
### 2. Facade Installation Decision (Line 942-972)
|
||||
|
||||
**Function:** `NostrLite._setupWindowNostrFacade()`
|
||||
|
||||
```
|
||||
Extension detected? ──YES──▶ DON'T install facade
|
||||
Store reference for persistence
|
||||
│
|
||||
NO
|
||||
▼
|
||||
Install WindowNostr facade ──▶ Handle local/NIP-46/readonly methods
|
||||
```
|
||||
|
||||
### 3. FloatingTab Click Behavior (Line 351-369)
|
||||
|
||||
**Current UX Inconsistency Issue:**
|
||||
|
||||
```javascript
|
||||
async _handleClick() {
|
||||
if (this.isAuthenticated && this.options.behavior.showUserInfo) {
|
||||
this._showUserMenu(); // Show user options
|
||||
} else {
|
||||
// INCONSISTENCY: Auto-tries extension instead of opening modal
|
||||
if (window.nostr && this._isRealExtension(window.nostr)) {
|
||||
await this._tryExtensionLogin(window.nostr); // Automatic extension attempt
|
||||
} else {
|
||||
if (this.modal) {
|
||||
this.modal.open({ startScreen: 'login' }); // Fallback to modal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Comparison with Login Button behavior:**
|
||||
- Login Button: **Always** opens modal for user choice
|
||||
- FloatingTab: **Auto-tries extension first**, only shows modal if denied
|
||||
|
||||
### 4. Authentication Restoration on Page Refresh
|
||||
|
||||
**Two-Path System:**
|
||||
|
||||
#### Path 1: Extension Mode (Line 1115-1173)
|
||||
```javascript
|
||||
async _attemptExtensionRestore() {
|
||||
const authManager = new AuthManager({ isolateSession: this.options?.isolateSession });
|
||||
const storedAuth = await authManager.restoreAuthState();
|
||||
|
||||
if (!storedAuth || storedAuth.method !== 'extension') return null;
|
||||
|
||||
// Verify extension still works with same pubkey
|
||||
if (!window.nostr || !this._isRealExtension(window.nostr)) return null;
|
||||
|
||||
const currentPubkey = await window.nostr.getPublicKey();
|
||||
if (currentPubkey !== storedAuth.pubkey) return null;
|
||||
|
||||
// Dispatch nlAuthRestored event for UI updates
|
||||
window.dispatchEvent(new CustomEvent('nlAuthRestored', { detail: extensionAuth }));
|
||||
}
|
||||
```
|
||||
|
||||
#### Path 2: Non-Extension Mode (Line 1080-1098)
|
||||
```javascript
|
||||
// Uses facade's restoreAuthState method
|
||||
if (this.facadeInstalled && window.nostr?.restoreAuthState) {
|
||||
const restoredAuth = await window.nostr.restoreAuthState();
|
||||
|
||||
if (restoredAuth) {
|
||||
// Handle NIP-46 reconnection if needed
|
||||
if (restoredAuth.requiresReconnection) {
|
||||
this._showReconnectionPrompt(restoredAuth);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Storage Strategy (Line 1408-1414)
|
||||
|
||||
**Storage Type Selection:**
|
||||
```javascript
|
||||
if (options.isolateSession) {
|
||||
this.storage = sessionStorage; // Per-window isolation
|
||||
} else {
|
||||
this.storage = localStorage; // Cross-window persistence
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Event-Driven State Synchronization
|
||||
|
||||
**Key Events:**
|
||||
- `nlMethodSelected` - Dispatched when user completes authentication
|
||||
- `nlAuthRestored` - Dispatched when authentication is restored from storage
|
||||
- `nlLogout` - Dispatched when user logs out
|
||||
- `nlReconnectionRequired` - Dispatched when NIP-46 needs reconnection
|
||||
|
||||
**Event Listeners:**
|
||||
- FloatingTab listens to all auth events for UI updates (Line 271-295)
|
||||
- WindowNostr listens to nlMethodSelected/nlLogout for state management (Line 823-869)
|
||||
|
||||
## State Persistence Security Model
|
||||
|
||||
### By Authentication Method:
|
||||
|
||||
**Extension:**
|
||||
- ✅ Store: pubkey, verification metadata
|
||||
- ❌ Never store: extension object, secrets
|
||||
- 🔒 Security: Minimal data, 1-hour expiry
|
||||
|
||||
**Local Key:**
|
||||
- ✅ Store: encrypted secret key, pubkey
|
||||
- 🔒 Security: AES-GCM encryption with session-specific password
|
||||
- 🔑 Session password stored in sessionStorage (cleared on tab close)
|
||||
|
||||
**NIP-46:**
|
||||
- ✅ Store: connection parameters, pubkey
|
||||
- ❌ Never store: session secrets
|
||||
- 🔄 Requires: User reconnection on restore
|
||||
|
||||
**Read-only:**
|
||||
- ✅ Store: method type, pubkey
|
||||
- ❌ No secrets to store
|
||||
|
||||
## Current Issues Identified
|
||||
|
||||
### UX Inconsistency (THE MAIN ISSUE)
|
||||
**Problem:** FloatingTab and Login Button have different click behaviors
|
||||
- **FloatingTab:** Auto-tries extension → Falls back to modal if denied
|
||||
- **Login Button:** Always opens modal for user choice
|
||||
|
||||
**Impact:**
|
||||
- Confusing user experience
|
||||
- Inconsistent interaction patterns
|
||||
- Users don't get consistent choice of authentication method
|
||||
|
||||
**Root Cause:** Line 358-367 in FloatingTab._handleClick() method
|
||||
|
||||
### Proposed Solutions:
|
||||
|
||||
#### Option 1: Make FloatingTab Consistent (Recommended)
|
||||
```javascript
|
||||
async _handleClick() {
|
||||
if (this.isAuthenticated && this.options.behavior.showUserInfo) {
|
||||
this._showUserMenu();
|
||||
} else {
|
||||
// Always open modal - consistent with login button
|
||||
if (this.modal) {
|
||||
this.modal.open({ startScreen: 'login' });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Option 2: Add Configuration Option
|
||||
```javascript
|
||||
floatingTab: {
|
||||
behavior: {
|
||||
autoTryExtension: false, // Default to consistent behavior
|
||||
// ... other options
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## ⚠️ IMPLEMENTATION STATUS: READY FOR CODE CHANGES
|
||||
|
||||
**User Decision:** FloatingTab should behave exactly like login buttons - always open modal for authentication method selection.
|
||||
|
||||
**Required Changes:**
|
||||
1. **File:** `lite/build.js`
|
||||
2. **Method:** `FloatingTab._handleClick()` (lines 351-369)
|
||||
3. **Action:** Remove extension auto-detection, always open modal
|
||||
|
||||
**Current Code to Replace (lines 358-367):**
|
||||
```javascript
|
||||
// Check if extension is available for direct login
|
||||
if (window.nostr && this._isRealExtension(window.nostr)) {
|
||||
console.log('FloatingTab: Extension available, attempting direct extension login');
|
||||
await this._tryExtensionLogin(window.nostr);
|
||||
} else {
|
||||
// Open login modal
|
||||
if (this.modal) {
|
||||
this.modal.open({ startScreen: 'login' });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Replacement Code:**
|
||||
```javascript
|
||||
// Always open login modal (consistent with login buttons)
|
||||
if (this.modal) {
|
||||
this.modal.open({ startScreen: 'login' });
|
||||
}
|
||||
```
|
||||
|
||||
**Critical Safety Notes:**
|
||||
- ✅ **DO NOT** change `_checkExistingAuth()` method (lines 299-349) - this handles automatic restoration on page refresh
|
||||
- ✅ **ONLY** change the click handler to remove manual extension detection
|
||||
- ✅ Authentication restoration will continue to work properly via the separate restoration system
|
||||
- ✅ Extension detection logic remains intact for other purposes (storage, verification, etc.)
|
||||
|
||||
**After Implementation:**
|
||||
- Rebuild the library with `node lite/build.js`
|
||||
- Test that both floating tab and login buttons behave identically
|
||||
- Verify that automatic login restoration on page refresh still works properly
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Extension-First Architecture:** The system never interferes with real browser extensions
|
||||
2. **Dual Storage Support:** Supports both per-window (sessionStorage) and cross-window (localStorage) persistence
|
||||
3. **Security-First:** Sensitive data is always encrypted or not stored
|
||||
4. **Event-Driven:** All components communicate via custom events
|
||||
5. **Automatic Restoration:** Authentication state is automatically restored on page refresh when possible
|
||||
|
||||
The login logic is complex due to supporting multiple authentication methods, security requirements, and different storage strategies, but it provides a flexible and secure authentication system for Nostr applications.
|
||||
@@ -1,139 +0,0 @@
|
||||
# 🏰 NIP-46 Remote Signer (Bunker) Test Setup
|
||||
|
||||
This directory contains a complete NIP-46 remote signing setup for testing NOSTR_LOGIN_LITE.
|
||||
|
||||
## 🔧 Setup Overview
|
||||
|
||||
**Bunker**: A remote signer daemon that holds your private keys securely
|
||||
**Client**: Browser client that connects to the bunker to request signatures
|
||||
**NOSTR_LOGIN_LITE**: Connects to bunker for remote signing capability
|
||||
|
||||
## 🔑 Generated Keys
|
||||
|
||||
```
|
||||
Bunker Secret Key: a33767c3bd05bda47880119d6665b79e6f0eecdf8d025966b0b59a9366379d01
|
||||
Bunker Public Key: 7566048aa9df5b36428f2ce364797f7ac6f6d4a17ee566f0cd3fefcf35146b90
|
||||
```
|
||||
|
||||
## 🚀 Testing NIP-46 Remote Signing
|
||||
|
||||
### Step 1: Start the Bunker
|
||||
|
||||
```bash
|
||||
# Open a new terminal and run:
|
||||
./nip46-test/start-bunker.sh
|
||||
```
|
||||
|
||||
You'll see output like:
|
||||
```
|
||||
🔐 Starting NIP-46 Bunker Remote Signer...
|
||||
==============================================
|
||||
Bunker Public Key: 7566048aa9df5b36428f2ce364797f7ac6f6d4a17ee566f0cd3fefcf35146b90
|
||||
Secret key is securely held by bunker
|
||||
|
||||
🚀 Starting bunker daemon...
|
||||
{"time":"202X-XX-XXTXX:XX:XX.XXXZ","level":"info","msg":"starting bunker on ws://localhost:8080"}
|
||||
{"time":"202X-XX-XXTXX:XX:XX.XXXZ","level":"info","msg":"bunker ready to handle NIP-46 requests"}
|
||||
```
|
||||
|
||||
### Step 2: Test with NOSTR_LOGIN_LITE
|
||||
|
||||
Navigate to:
|
||||
```
|
||||
http://localhost:8000/examples/modal-login-demo.html
|
||||
```
|
||||
|
||||
Click "🚀 Launch Authentication Modal" and select **"NIP-46 Remote"** option.
|
||||
|
||||
The browser will connect to the bunker running on `ws://localhost:8080` and request signatures remotely.
|
||||
|
||||
## 🔄 How NIP-46 Works
|
||||
|
||||
```
|
||||
Browser (NOSTR_LOGIN_LITE) → WebSocket → Bunker (NAK on localhost:8080)
|
||||
↓ ↓
|
||||
Requests signature Holds private key
|
||||
↓ ↓
|
||||
Receives signed event Signs & returns result
|
||||
```
|
||||
|
||||
## 📁 Files in this Directory
|
||||
|
||||
- `start-bunker.sh` - Script to start the remote signer daemon
|
||||
- `bunker-config.js` - Configuration for NOSTR_LOGIN_LITE
|
||||
- `README.md` - This documentation
|
||||
|
||||
## 🧪 Testing Scenarios
|
||||
|
||||
### ✅ Successful Connection
|
||||
- Bunker runs on localhost:8080
|
||||
- Browser connects and requests pubkey
|
||||
- Bunker responds with `7566048aa9df5b36428f2ce364797f7ac6f6d4a17ee566f0cd3fefcf35146b90`
|
||||
|
||||
### 🔧 Signature Requests
|
||||
- Browser sends event to sign
|
||||
- Bunker signs with private key
|
||||
- Signed event returned to browser
|
||||
- Browser publishes signed event to relay
|
||||
|
||||
### 🐛 Debug Issues
|
||||
- Check bunker logs for connection errors
|
||||
- Verify WebSocket connection in browser dev tools
|
||||
- Look for NIP-46 protocol errors
|
||||
|
||||
## 📝 NOSTR_LOGIN_LITE Configuration
|
||||
|
||||
In your app, configure remote signing like this:
|
||||
|
||||
```javascript
|
||||
const nip46Config = {
|
||||
type: "nip46",
|
||||
bunker: {
|
||||
pubkey: "7566048aa9df5b36428f2ce364797f7ac6f6d4a17ee566f0cd3fefcf35146b90",
|
||||
url: "ws://your-bunker-server:8080" // Production URL
|
||||
}
|
||||
};
|
||||
|
||||
await window.NOSTR_LOGIN_LITE.init(nip46Config);
|
||||
```
|
||||
|
||||
## ⚠️ Production Notes
|
||||
|
||||
- **This setup uses localhost** - replace with real server URL in production
|
||||
- **Private key is shown for testing** - production bunkers should be secured
|
||||
- **WebSocket URL should be secure** (wss://) in production
|
||||
- **Consider authentication** for your bunker to prevent unauthorized access
|
||||
|
||||
## 🎯 Common Testing Commands
|
||||
|
||||
### Check NAK version
|
||||
```bash
|
||||
nak --version
|
||||
```
|
||||
|
||||
### Generate new keys (if needed)
|
||||
```bash
|
||||
nak key generate # Secret key
|
||||
echo "your_secret_key_here" | nak key public # Public key
|
||||
```
|
||||
|
||||
### Manual bunker test
|
||||
```bash
|
||||
nak bunker --sec "your_secret_key" --port 8080 --relay "wss://relay.damus.io"
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
**Bunker won't start:**
|
||||
- Check if port 8080 is free
|
||||
- Verify NAK is installed correctly
|
||||
|
||||
**Browser can't connect:**
|
||||
- Check firewall settings
|
||||
- Verify bunker is running (`ps aux | grep nak`)
|
||||
- Check browser console for WebSocket errors
|
||||
|
||||
**Signing fails:**
|
||||
- Verify keys are correct
|
||||
- Check bunker logs for errors
|
||||
- Ensure event format is valid
|
||||
Submodule
+1
Submodule nostr-tools added at 23aebbd341
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"liveServer.settings.port": 5501
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
# CYD Web Serial signer integration
|
||||
|
||||
Add support for the new n_signer hardware board (CYD: ESP32-2432S028, the resistive-touch ILI9341 board) to `nostr_login_lite`, alongside the existing Feather WebUSB signer.
|
||||
|
||||
## Background
|
||||
|
||||
The existing [`NSignerWebUSB`](../src/signers/nsigner-webusb.js:1) talks to the Feather S3 board over **WebUSB** because the Feather has a native USB peripheral, enumerates with VID `0x303a`, and exposes a vendor-class interface (class 0xFF) plus a control transfer (`0x22, value=1`) that switches the device into WebUSB mode.
|
||||
|
||||
The new CYD board does **not** have native USB. The ESP32 talks to the host through a CH340 (or, on some revisions, a CP2102) USB-to-UART bridge chip, so the host enumerates a plain CDC serial device. WebUSB cannot reach it; the Web Serial API (`navigator.serial`) can.
|
||||
|
||||
On the firmware side the protocol is unchanged: both Feather and CYD speak the same 4-byte big-endian length-prefixed JSON-RPC framing — only the underlying byte stream is different (USB bulk endpoint vs. UART byte stream). All RPC semantics (auth envelope kind 27235, nsigner_rpc / nsigner_method / nsigner_body_hash tags, `get_public_key`, `sign_event`, `nip04_encrypt`, `nip04_decrypt`, `nip44_encrypt`, `nip44_decrypt`) are identical.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Add `NSignerWebSerial` to `src/signers/` with the same public API as [`NSignerWebUSB`](../src/signers/nsigner-webusb.js:1) so the rest of `nostr_login_lite` (modal, login flow, build pipeline) is agnostic to which board is connected.
|
||||
2. Add a `cyd_webserial_demo.html` example mirroring [`feather_webusb_demo.html`](../examples/feather_webusb_demo.html), so a user can manually verify `get_public_key`, `sign_event`, NIP-04 and NIP-44 roundtrips against the CYD without booting the full SDK.
|
||||
3. Update the login modal so the user can pick "Feather (WebUSB)" or "CYD (Serial)" at connect time.
|
||||
4. Bundle the new signer into `nostr_login_lite.js` via `build.js`.
|
||||
5. Document browser support, CH340 driver setup, and DTR/RTS behavior.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Touching the firmware: the CYD firmware in `n_signer/firmware/cyd_esp32_2432s028/` already exposes the correct protocol on UART0 at 115200 8N1 with no flow control.
|
||||
- Replacing the existing `NSignerWebUSB`. Both transports coexist; users with Feather hardware continue to use WebUSB.
|
||||
- Supporting Firefox/Safari. Web Serial (and WebUSB) are Chromium-only. This is a known limitation, not a regression.
|
||||
|
||||
## Transport mapping
|
||||
|
||||
| Concern | WebUSB (Feather) | Web Serial (CYD) |
|
||||
|---|---|---|
|
||||
| Device picker | `navigator.usb.requestDevice({filters:[{vendorId:0x303a}]})` | `navigator.serial.requestPort({filters:[{usbVendorId:0x1a86, usbProductId:0x7523}, {usbVendorId:0x10c4, usbProductId:0xea60}]})` |
|
||||
| Open | `dev.open(); selectConfiguration(1); claimInterface; selectAlternateInterface; controlTransferOut(req=0x22, value=1)` | `port.open({baudRate:115200, dataBits:8, parity:"none", stopBits:1, flowControl:"none"})` |
|
||||
| DTR/RTS | n/a | `port.setSignals({dataTerminalReady:false, requestToSend:false})` immediately after open to avoid pulsing EN/IO0 (which would reset the ESP32 or put it in download mode) |
|
||||
| Already-paired discovery | `navigator.usb.getDevices()` | `navigator.serial.getPorts()` |
|
||||
| Write | `dev.transferOut(EP_OUT, frame)` | `writer = port.writable.getWriter(); await writer.write(frame); writer.releaseLock()` |
|
||||
| Read | `dev.transferIn(EP_IN, 512)` returning `DataView` | `reader = port.readable.getReader(); { value, done } = await reader.read(); reader.releaseLock()` (or hold the reader for the lifetime of the connection — see below) |
|
||||
| Disconnect event | `navigator.usb.addEventListener('disconnect', ...)` | `port.addEventListener('disconnect', ...)` plus the reader stream throwing on unplug |
|
||||
| Close | `releaseInterface(); device.close()` | `await reader.cancel(); await port.close()` |
|
||||
|
||||
Framing protocol is identical: `[4-byte big-endian length][JSON body]`. The CYD firmware in [`uart_transport.c`](../../../n_signer/firmware/cyd_esp32_2432s028/main/uart_transport.c) already tolerates leading non-frame bytes (early boot logs) by sliding the parser one byte at a time when the length header is invalid, so the client doesn't need to coordinate boot timing.
|
||||
|
||||
## Design
|
||||
|
||||
### `NSignerWebSerial` class
|
||||
|
||||
File: `src/signers/nsigner-webserial.js`.
|
||||
|
||||
Public API mirrors [`NSignerWebUSB`](../src/signers/nsigner-webusb.js:1) exactly:
|
||||
|
||||
```text
|
||||
static FILTERS // [{usbVendorId:0x1a86, usbProductId:0x7523}, {usbVendorId:0x10c4, usbProductId:0xea60}]
|
||||
static async requestAndConnect(options) // picker -> open -> return driver
|
||||
static async getPairedDevice(options) // navigator.serial.getPorts() -> first matching
|
||||
static randomSecretHex() // unchanged from WebUSB version
|
||||
static toPubkeyHex(secretHex) // unchanged
|
||||
constructor(port, { callerSecretKey, nostrIndex })
|
||||
get isOpen, vendorId, productId, serial
|
||||
onDisconnect(cb)
|
||||
async open()
|
||||
async close()
|
||||
async getPublicKey()
|
||||
async signEvent(unsignedEvent)
|
||||
async nip04Encrypt(peerHex, plaintext)
|
||||
async nip04Decrypt(peerHex, ciphertext)
|
||||
async nip44Encrypt(peerHex, plaintext)
|
||||
async nip44Decrypt(peerHex, ciphertext)
|
||||
```
|
||||
|
||||
Internal differences from the WebUSB class:
|
||||
|
||||
1. **Constructor takes a `SerialPort`** (from `navigator.serial`) rather than a `USBDevice`.
|
||||
2. **`vendorId` / `productId` / `serial`** come from `port.getInfo()` (`usbVendorId`, `usbProductId`). `serial` is generally unavailable on Web Serial — keep the field for API parity but expect `null`.
|
||||
3. **Single long-lived reader.** Web Serial's reader, unlike WebUSB's `transferIn` calls, owns the readable stream for as long as it's locked. The cleanest pattern is:
|
||||
- On `open()`, start a background `_readLoop()` task that acquires the reader once, accumulates bytes into a ring buffer, parses frames, and resolves the pending RPC promise keyed by `req.id`.
|
||||
- `_sendRpc()` registers `{id, resolve, reject, timer}` in a `Map`, writes the frame via the writer, and awaits the registered promise.
|
||||
- On `close()`, call `reader.cancel()` to break out of the loop, then `port.close()`.
|
||||
4. **DTR/RTS handling.** Right after `port.open()`, call `port.setSignals({dataTerminalReady: false, requestToSend: false})`. The CH340's RTS/DTR are wired to the ESP32 EN/IO0 reset/boot pins through a small transistor network on the CYD; the auto-reset-into-bootloader sequence triggers when esptool toggles them in a specific pattern. We never want either asserted during normal operation. Test empirically — if it turns out the CYD revision in use ignores them, we leave the call as a defensive no-op.
|
||||
5. **Reconnect detection.** The `disconnect` event on the port fires when the user unplugs the cable; our loop's `reader.read()` will resolve `{done:true}` shortly after. Both paths should call the registered `_disconnectHandlers`.
|
||||
6. **Frame parser.** Identical to the WebUSB ring-buffer parser, including the "slide one byte and retry" recovery when the length header is invalid (e.g., during the CYD bootloader's early-boot stdout noise).
|
||||
7. **Auth envelope construction.** Reuse the kind-27235 builder verbatim. Extract `_buildAuth`, `_be32`, `_hex`, `_hexToBytes`, `_sha256Hex`, `_utf8` into a shared helper module (e.g. `src/signers/_auth.js`) so both classes import the same code rather than duplicating it. Optional polish — not required for the first cut.
|
||||
|
||||
### Standalone demo: `examples/cyd_webserial_demo.html`
|
||||
|
||||
Copy [`feather_webusb_demo.html`](../examples/feather_webusb_demo.html). Replace only:
|
||||
- Title and intro text ("n_signer CYD Web Serial Demo").
|
||||
- `connect()` function: swap WebUSB calls for Web Serial as per the transport mapping above.
|
||||
- The `sendRpc()` loop: use the long-lived reader pattern.
|
||||
- A small "Disconnect" button that calls `port.close()` cleanly.
|
||||
|
||||
Keep all the auth-envelope code, kind 1 signer, NIP-04/44 sections unchanged.
|
||||
|
||||
### Modal / login flow
|
||||
|
||||
In `src/ui/modal.js`, where the WebUSB option is rendered, add a sibling "CYD (Serial)" option. On click, instantiate `NSignerWebSerial` instead of `NSignerWebUSB`. The downstream code already uses the common interface so no further plumbing should be needed.
|
||||
|
||||
### Build pipeline
|
||||
|
||||
In `build.js`, add `src/signers/nsigner-webserial.js` (and the shared `_auth.js` if extracted) to the concatenation list, exposing `window.NSignerWebSerial` so non-bundled consumers can use it directly the same way [`window.NSignerWebUSB`](../src/signers/nsigner-webusb.js:355) is exposed today.
|
||||
|
||||
## DTR/RTS / CH340 caveats
|
||||
|
||||
- The CH340 datasheet specifies DTR and RTS are open-collector outputs. On the CYD, these tie through transistors to the ESP32 EN (reset) and IO0 (boot mode) pins respectively. esptool relies on this circuit for one-click flashing.
|
||||
- Linux's `cdc-acm` driver (well, the `ch341` kernel driver) toggles DTR/RTS on `open(2)` by default, which is why naively running `cat /dev/ttyUSB0` resets the board. Web Serial does NOT do this automatically as far as Chromium's implementation goes — but to be safe we explicitly set both to `false` after open.
|
||||
- If the board resets on connect anyway, mitigations:
|
||||
- Add a 10 µF capacitor between EN and GND (a common hardware fix; out of scope for this plan).
|
||||
- In software, after `port.open()`, immediately call `setSignals({dataTerminalReady:false, requestToSend:false})`, then wait ~200 ms before doing anything else, then drain and discard any pending bytes (since the firmware will spew boot logs).
|
||||
- If the user does see a reset, the client should be resilient: a reset means `port.readable` will close, the disconnect event fires, but the OS-level serial port may still exist. The client should NOT try to reopen automatically; surface "device reset, please reconnect" to the user.
|
||||
|
||||
## Browser support and udev
|
||||
|
||||
- **Web Serial**: Chrome 89+, Edge 89+, Brave, Opera. Not in Firefox or Safari.
|
||||
- **Linux**: the user's account must be in `dialout` (Debian/Ubuntu) or `uucp` (Arch) for unprivileged access. ChromeOS exposes serial ports via permission prompts. macOS and Windows: no extra setup beyond the CH340/CP2102 driver.
|
||||
- **macOS CH340 driver**: WCH-IC ships a driver for older macOS; macOS 11+ has an in-tree driver but it sometimes conflicts with old kexts. Document this in the README.
|
||||
- **Windows CH340 driver**: WCH-IC's official driver is required on Windows 10/11 (the in-box `usbser` driver does not auto-bind to all CH340 PID variants).
|
||||
|
||||
## Validation
|
||||
|
||||
1. Open `cyd_webserial_demo.html` in Chrome, click Connect, pick the CYD port.
|
||||
2. Verify "Connected" status appears; auto-fetch returns the device's `pubkey` (64-hex).
|
||||
3. Sign a kind 1 event — on the CYD's touchscreen the approval prompt should appear; tap "Approve" — the demo logs the signed event.
|
||||
4. Tap "Always" once for nip04_encrypt, run an encrypt/decrypt roundtrip, then for nip44. Confirm second invocation of the same method skips the approval prompt due to `Always` caching on-device.
|
||||
5. Tap "Deny" on one — confirm the client receives a deny error.
|
||||
6. Unplug the cable — confirm the modal/SDK fires `onDisconnect`.
|
||||
|
||||
## Risks / open questions
|
||||
|
||||
- **CH340 vs CP2102 hardware revisions.** The two known VID/PID pairs cover most CYDs; if a third variant appears (e.g., FTDI FT232R, `0x0403:0x6001`), add a third filter entry. Web Serial allows multiple filters in one `requestPort` call so the picker shows any matching device.
|
||||
- **`SerialPort.getInfo()`** on some Chrome versions returns `null` vendor/product IDs on Linux when the kernel driver doesn't expose them through `/sys/`. The fallback is to show all ports in the picker (no filter) — slightly worse UX but functional.
|
||||
- **No serial number** is exposed by CH340/CP2102 chips on CYDs, so the "remember this device" UX from `NSignerWebUSB.getPairedDevice` will only be able to match on VID/PID, not serial. If multiple CYDs are connected, the user picks each time.
|
||||
- **Concurrent access.** Only one tab can open a given serial port at a time. If the user has the Arduino IDE serial monitor or `idf.py monitor` running on the same port, Web Serial's `port.open()` will throw `NetworkError`. Document this.
|
||||
|
||||
## Phasing
|
||||
|
||||
| Phase | Deliverable |
|
||||
|---|---|
|
||||
| 1 | `cyd_webserial_demo.html` — standalone, no SDK. Lets us validate the transport works end-to-end against the CYD firmware. |
|
||||
| 2 | `src/signers/nsigner-webserial.js` — class with full API parity. |
|
||||
| 3 | Modal integration in `src/ui/modal.js`; build pipeline update in `build.js`. |
|
||||
| 4 | Documentation: README updates (browser support, driver setup, udev rule note), plus a short user-facing note in the n_signer-side `firmware/README.md` linking to the new demo. |
|
||||
|
||||
Phase 1 is the recommended first step because the firmware is already done — landing a working demo verifies the transport and unblocks Phase 2 with a known-good wire format to imitate.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `cyd_webserial_demo.html` performs a full get_public_key / sign_event / nip04 roundtrip / nip44 roundtrip against a CYD device.
|
||||
- `NSignerWebSerial` passes the same smoke tests as `NSignerWebUSB`, with the same public surface (so any existing call site that takes either driver works without changes).
|
||||
- Login modal renders both transport options and produces an equivalent session for either path.
|
||||
- The Feather WebUSB path continues to work unchanged.
|
||||
|
||||
## Question raised by the user
|
||||
|
||||
> Is our new board ready to work with `nostr_login_lite` once Phase 1–3 land?
|
||||
|
||||
**Yes.** The CYD firmware in `n_signer/firmware/cyd_esp32_2432s028/` already implements the full JSON-RPC surface (`get_public_key`, `sign_event`, `nip04_encrypt`, `nip04_decrypt`, `nip44_encrypt`, `nip44_decrypt`) over UART0, with the same auth envelope and approval UI as the Feather. Once `NSignerWebSerial` exists and the modal offers it, `nostr_login_lite` will treat the CYD as a fully-supported signer. The only remaining gating items are the optional polish in this plan (touch calibration persistence and the CYD-side flash helper script — both are out of scope here and tracked on the n_signer firmware side).
|
||||
@@ -0,0 +1,351 @@
|
||||
# n_signer WebUSB Integration
|
||||
|
||||
Add WebUSB support for the [`n_signer`](../../n_signer/README.md:1) hardware signer (Feather S3 firmware) to [`nostr_login_lite`](../src/build.js:1) as a new login method in the modal. Any host page that consumes `window.nostr` automatically gets hardware signing — without per-page edits.
|
||||
|
||||
## 1. Why WebUSB is the only thing to add
|
||||
|
||||
`n_signer` is a multi-transport signer (one core dispatcher, many wire formats), but from inside a browser tab the picture is simple:
|
||||
|
||||
| n_signer transport | Reachable from this lib today? | Action |
|
||||
|---|---|---|
|
||||
| **WebUSB** (Feather S3, VID:PID `303a:4001`, framed JSON-RPC) | ✅ Yes | **This plan.** New code. |
|
||||
| **NIP-46 bunker** (deferred upstream — [`plans/nip46_bunker_mode.md`](../../n_signer/plans/nip46_bunker_mode.md:1)) | ✅ When upstream ships | Free — uses existing `connect` tile. No code. |
|
||||
| **Browser extension** (deferred upstream — [`plans/nsigner_browser_extension.md`](../../n_signer/plans/nsigner_browser_extension.md:1)) | ✅ When upstream ships | Free — uses existing `extension` tile. No code. |
|
||||
| AF_UNIX, qrexec, stdio, raw TCP, CDC serial | ❌ Browser physics | Not in scope. |
|
||||
|
||||
So the entire integration is: **add a WebUSB tile.** The other paths land for free as `n_signer` ships them, through the modal options the lib already has.
|
||||
|
||||
## 2. What's already in place on both sides
|
||||
|
||||
### 2.1 `n_signer` (Feather S3 firmware)
|
||||
|
||||
- Composite USB device, **VID:PID `303a:4001`**, exposes a Vendor / WebUSB interface ([`firmware/README.md`](../../n_signer/firmware/README.md:7)).
|
||||
- Wire format: **4-byte big-endian length prefix + JSON body**, JSON-RPC 2.0 shape ([`README.md`](../../n_signer/README.md:172)).
|
||||
- Verbs we need: `get_public_key`, `sign_event`, `nip04_encrypt` / `nip04_decrypt`, `nip44_encrypt` / `nip44_decrypt` ([`README.md`](../../n_signer/README.md:183)).
|
||||
- Selector: `{ nostr_index: N }`.
|
||||
- Every WebUSB request must carry an **auth envelope**: a kind-27235 Nostr event signed by the *caller's* keypair, with required tags `nsigner_rpc`, `nsigner_method`, `nsigner_body_hash` (full spec: [`plans/caller_token_identity.md`](../../n_signer/plans/caller_token_identity.md:55)). Reference builder: [`feather_webusb_demo.html`](../../n_signer/examples/feather_webusb_demo.html:279).
|
||||
|
||||
### 2.2 `nostr_login_lite` (this repo)
|
||||
|
||||
The lib already has the right shape for "yet another signing method":
|
||||
|
||||
- Modal tiles render in [`src/ui/modal.js`](../src/ui/modal.js:201) and dispatch in `_handleOptionClick()` at [`src/ui/modal.js`](../src/ui/modal.js:335).
|
||||
- The `WindowNostr` facade at [`src/build.js`](../src/build.js:1937) already switches on `this.authState.method` for all six NIP-07 methods. Switch locations:
|
||||
|
||||
| NIP-07 method | Switch line |
|
||||
|---|---|
|
||||
| `getPublicKey()` | [`src/build.js`](../src/build.js:2022) |
|
||||
| `signEvent()` | [`src/build.js`](../src/build.js:2050) |
|
||||
| `nip04.encrypt` | [`src/build.js`](../src/build.js:2102) |
|
||||
| `nip04.decrypt` | [`src/build.js`](../src/build.js:2144) |
|
||||
| `nip44.encrypt` | [`src/build.js`](../src/build.js:2190) |
|
||||
| `nip44.decrypt` | [`src/build.js`](../src/build.js:2232) |
|
||||
|
||||
- `AuthManager` persistence is keyed on `authData.method`: persist switch at [`src/build.js`](../src/build.js:1410), restore switch at [`src/build.js`](../src/build.js:1488).
|
||||
|
||||
We add tile + matching `case` arms; no surface change.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Page[Host page using window.nostr]
|
||||
UI[App calls window.nostr.signEvent]
|
||||
end
|
||||
|
||||
subgraph Lite[nostr_login_lite]
|
||||
Modal[Modal: pick n_signer USB]
|
||||
Facade[WindowNostr facade]
|
||||
Auth[AuthManager persistence]
|
||||
end
|
||||
|
||||
subgraph Driver[NSignerWebUSB driver]
|
||||
USB[WebUSB transport: 4-byte BE length + JSON]
|
||||
RPC[JSON-RPC client]
|
||||
AuthEnv[kind-27235 auth envelope signer]
|
||||
end
|
||||
|
||||
Device[Feather S3 n_signer hardware]
|
||||
|
||||
UI --> Facade
|
||||
Modal -- _setAuthMethod nsigner --> Facade
|
||||
Facade -- getPublicKey/signEvent/nip04/nip44 --> Driver
|
||||
Driver --> USB
|
||||
USB --> Device
|
||||
RPC --> USB
|
||||
AuthEnv --> RPC
|
||||
Modal -- saves caller key + index --> Auth
|
||||
Auth -- restores on reload --> Facade
|
||||
```
|
||||
|
||||
The **driver** is pure WebUSB + framing + JSON-RPC. The **facade** holds session state (`authState.signer.driver`, `nostrIndex`, caller keypair).
|
||||
|
||||
## 4. Driver module — `src/signers/nsigner-webusb.js`
|
||||
|
||||
New file. Distilled from [`feather_webusb_demo.html`](../../n_signer/examples/feather_webusb_demo.html:197) into an ES module / class.
|
||||
|
||||
### 4.1 Public surface
|
||||
|
||||
```js
|
||||
class NSignerWebUSB {
|
||||
static FILTERS = [{ vendorId: 0x303a, productId: 0x4001 }];
|
||||
|
||||
static async requestAndConnect(opts) // navigator.usb.requestDevice + open + claim
|
||||
static async getPairedDevice(opts) // navigator.usb.getDevices() match for auto-restore
|
||||
|
||||
constructor(usbDevice, { callerSecretKey, nostrIndex = 0 })
|
||||
async open() // selectConfiguration, claim vendor iface, controlTransferOut(0x22, 1)
|
||||
async close() // release + close, disconnect listener cleanup
|
||||
isOpen
|
||||
vendorId / productId / serial
|
||||
|
||||
async getPublicKey() // -> hex x-only
|
||||
async signEvent(unsignedEvent) // -> signed event with id/pubkey/sig
|
||||
async nip04Encrypt(peerHex, plaintext)
|
||||
async nip04Decrypt(peerHex, ciphertext)
|
||||
async nip44Encrypt(peerHex, plaintext)
|
||||
async nip44Decrypt(peerHex, ciphertext)
|
||||
|
||||
onDisconnect(cb) // forwarded from navigator.usb.ondisconnect
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Internals (proven in the demo, just refactored)
|
||||
|
||||
- `_sendRpc(req)` — frame as `4-byte BE length || JSON`, `transferOut(EP_OUT=1, …)`, then `transferIn(EP_IN=1, 512)` until a full frame is reassembled. Same ring-buffer logic as [`feather_webusb_demo.html`](../../n_signer/examples/feather_webusb_demo.html:310).
|
||||
- `_buildAuth(method, params)` — kind 27235 with required tags `nsigner_rpc`, `nsigner_method`, `nsigner_body_hash`, signed by `callerSecretKey` using `window.NostrTools.schnorr` (already in the bundle via `nostr-tools`). Demo reference: [`feather_webusb_demo.html`](../../n_signer/examples/feather_webusb_demo.html:279).
|
||||
- All RPC params append `{ nostr_index }`. Configurable per call so future UI can switch identities without reconnect.
|
||||
- Single in-flight request mutex — the firmware dispatches one at a time and the wire is one bulk endpoint pair.
|
||||
|
||||
The driver does **no UI** and does **no persistence**. Both stay in `nostr_login_lite`.
|
||||
|
||||
## 5. `nostr_login_lite` changes
|
||||
|
||||
### 5.1 Modal — new tile
|
||||
|
||||
In [`src/ui/modal.js`](../src/ui/modal.js:201) `_renderLoginOptions()`, gated by `this.options?.methods?.nsigner !== false`:
|
||||
|
||||
```js
|
||||
options.push({
|
||||
type: 'nsigner',
|
||||
title: 'USB Hardware signer',
|
||||
description: 'Sign with USB-connected n_signer hardware',
|
||||
icon: '🔐'
|
||||
});
|
||||
```
|
||||
|
||||
Add a `case 'nsigner': this._showNSignerScreen()` in the dispatch switch at [`src/ui/modal.js`](../src/ui/modal.js:339).
|
||||
|
||||
Feature-detect at modal render:
|
||||
|
||||
- `!('usb' in navigator)` → tile shows disabled with a "Chrome/Edge required" hint.
|
||||
- `!window.isSecureContext` → tile shows disabled with a "Requires HTTPS or localhost" hint (see §11 for why).
|
||||
|
||||
Mirrors how `extension` handles a missing `window.nostr`.
|
||||
|
||||
### 5.2 Modal — connect screen `_showNSignerScreen()`
|
||||
|
||||
UI elements:
|
||||
|
||||
- **"Connect Device"** button → calls `NSignerWebUSB.requestAndConnect()`.
|
||||
- Numeric input for **Nostr key index** (`nostr_index`, default `0`). Persisted with the auth state.
|
||||
- Status line that reads back the resolved pubkey once `getPublicKey` returns, for human verification against the device's TFT.
|
||||
- A persistent "look at the device" hint while requests are in-flight (the on-device TFT may be prompting for approval per [`plans/feather_signer_ui.md`](../../n_signer/plans/feather_signer_ui.md:1)).
|
||||
- **"Use this device"** → `_setAuthMethod('nsigner', { pubkey, signer: { driver, nostrIndex, callerPubkey } })`.
|
||||
|
||||
The screen also generates (or loads) the **caller keypair** for the auth envelope (per [`plans/caller_token_identity.md`](../../n_signer/plans/caller_token_identity.md:13)). Per-app, per-installation, generated once and persisted (§5.4). The first request triggers an on-device approval prompt — expected behavior; surface a "approve on device" hint while waiting.
|
||||
|
||||
### 5.3 `WindowNostr` facade — new branch
|
||||
|
||||
In [`src/build.js`](../src/build.js:1937) add a `case 'nsigner'` arm to each switch:
|
||||
|
||||
| Method | Switch location | Action |
|
||||
|---|---|---|
|
||||
| `getPublicKey()` | [`src/build.js`](../src/build.js:2022) | Return cached `authState.pubkey`. Verified at login; no round-trip per call. |
|
||||
| `signEvent(event)` | [`src/build.js`](../src/build.js:2050) | `await authState.signer.driver.signEvent(event)`. |
|
||||
| `nip04.encrypt` | [`src/build.js`](../src/build.js:2102) | `driver.nip04Encrypt(peer, text)`. |
|
||||
| `nip04.decrypt` | [`src/build.js`](../src/build.js:2144) | `driver.nip04Decrypt(peer, ct)`. |
|
||||
| `nip44.encrypt` | [`src/build.js`](../src/build.js:2190) | `driver.nip44Encrypt(peer, text)`. |
|
||||
| `nip44.decrypt` | [`src/build.js`](../src/build.js:2232) | `driver.nip44Decrypt(peer, ct)`. |
|
||||
|
||||
The facade keeps a single `NSignerWebUSB` instance for the session. If the device is unplugged mid-session, `onDisconnect` fires, the facade clears the live driver, any pending call rejects with a recognizable error, and the modal can offer a "Reconnect device" path using `_attemptNSignerRestore()` (§5.4).
|
||||
|
||||
### 5.4 Persistence — `AuthManager`
|
||||
|
||||
Hardware signing has no master secret to encrypt. Persist:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"method": "nsigner",
|
||||
"pubkey": "<hex of the device-derived nostr pubkey>",
|
||||
"nostrIndex": 0,
|
||||
"callerSecretKey": "<hex>",
|
||||
"callerPubkey": "<hex>",
|
||||
"deviceVid": 12346,
|
||||
"deviceProductId": 16385,
|
||||
"deviceSerial": "<usb serial if available>",
|
||||
"timestamp": 1730000000000
|
||||
}
|
||||
```
|
||||
|
||||
Storage location follows existing `isolateSession` rules in [`src/build.js`](../src/build.js:1408). `callerSecretKey` is the only secret here and **is intentionally not** a Nostr identity — it is the per-app caller token (the identity the device approves once). Encrypting it with the same scheme used for `local` keys is a small non-blocking improvement.
|
||||
|
||||
Add a new branch in the persist switch at [`src/build.js`](../src/build.js:1410) (next to `'extension'`, `'local'`, `'nip46'`) and the matching restore branch at [`src/build.js`](../src/build.js:1488).
|
||||
|
||||
On page reload, [`src/build.js`](../src/build.js:1115) gets a new `_attemptNSignerRestore()`:
|
||||
|
||||
1. `navigator.usb.getDevices()` — already-permitted devices return without a prompt (per WebUSB spec).
|
||||
2. Match `vid==0x303a && pid==0x4001`. If multiple, match `serialNumber` if stored.
|
||||
3. If found, `open()`, then `getPublicKey()` and verify it matches stored `pubkey`. Mismatch → treat as logout (the user re-paired the device with a different mnemonic).
|
||||
4. If no device is currently plugged in, dispatch `nlReconnectionRequired` (analogous to the NIP-46 path) so the host page can show a "Plug in your signer" prompt instead of silently failing.
|
||||
|
||||
### 5.5 Logout
|
||||
|
||||
`NOSTR_LOGIN_LITE.logout()` already dispatches `nlLogout`. Add a listener in the facade to call `driver.close()` and zeroize the cached caller key.
|
||||
|
||||
## 6. Open questions
|
||||
|
||||
1. **Caller-key generation.** The demo at [`feather_webusb_demo.html`](../../n_signer/examples/feather_webusb_demo.html:281) hard-codes `[1..32]` as the caller key. We must generate a fresh random caller key per browser profile (matches §1.2 of [`plans/caller_token_identity.md`](../../n_signer/plans/caller_token_identity.md:13)). Confirm.
|
||||
2. **Methods config gating.** Add a `methods.nsigner: false` opt-out in `init()`, mirroring existing `methods.{extension,local,seedphrase,connect,readonly,otp}` flags. Update [`README.md`](../README.md:21) accordingly.
|
||||
3. **Index switching at runtime.** Do we want a UI to switch `nostr_index` after login (effectively switching identities without re-pairing)? Easy to add via `NOSTR_LOGIN_LITE.setNSignerIndex(n)`. Defer to v1.1?
|
||||
4. **Optional encryption of `callerSecretKey` at rest.** It's not a Nostr identity, but encrypting it with the same scheme as `local` keys is cheap and reduces casual exfiltration.
|
||||
|
||||
## 7. Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| WebUSB unsupported in Firefox/Safari | Feature-detect `'usb' in navigator` at modal render; show the tile in a disabled state with a "Chrome/Edge required" tooltip. Mirrors how `extension` handles missing `window.nostr`. |
|
||||
| Linux udev rule needed | Show the udev install snippet from [`firmware/README.md`](../../n_signer/firmware/README.md:56) inline in the connect screen if `requestDevice` errors with `SecurityError`. |
|
||||
| Concurrent calls collide on the device | Driver mutex serializes RPCs. Tests cover overlapping `signEvent` from multiple async callers in the same page. |
|
||||
| Device unplug mid-flow | `usb.ondisconnect` → reject pending, dispatch `nlReconnectionRequired`, keep `authState` so reconnect resumes seamlessly. |
|
||||
| Approval prompts on the TFT block requests | Surface a "Approve on device" hint and a soft 30 s timeout that doesn't reject — keeps spinning until the device responds. |
|
||||
| Bundle size growth | Driver is small (~5–8 KB). Reuses `nostr-tools` schnorr already in the bundle. No new deps. |
|
||||
| User pairs a different mnemonic on the same physical device | Auto-restore step §5.4.3 detects the pubkey mismatch and forces re-login instead of silently signing with the wrong identity. |
|
||||
| Caller-key leak from `localStorage` | Document that `callerSecretKey` is per-app, not a Nostr identity. Optionally encrypt at rest with the same scheme as `local`. |
|
||||
|
||||
## 8. Test plan
|
||||
|
||||
### 8.1 Unit (driver, no device)
|
||||
|
||||
Mock `USBDevice`. Verify:
|
||||
|
||||
- 4-byte BE length framing (out and in).
|
||||
- Ring-buffer reassembly across split chunks of arbitrary boundaries.
|
||||
- Auth envelope produces a kind-27235 event whose `nsigner_body_hash` matches `SHA-256(JCS(params))`.
|
||||
- Auth envelope verifies with `nostr-tools` `verifyEvent()` round-trip.
|
||||
- Mutex serializes overlapping `signEvent` calls.
|
||||
- `onDisconnect` rejects in-flight calls with a recognizable error.
|
||||
|
||||
### 8.2 Manual (with real Feather S3 device)
|
||||
|
||||
- **Pair flow.** Open [`examples/sign.html`](../examples/sign.html), pick the n_signer tile, complete pairing, sign a kind-1 event.
|
||||
- **Auto-restore.** Reload the page; the device should re-attach via `navigator.usb.getDevices()` without a permission prompt; pubkey verifies; signing works without re-pairing.
|
||||
- **Unplug → replug.** While paired, unplug the device; `nlReconnectionRequired` should fire. Replug; "Reconnect device" button completes without re-entering the modal.
|
||||
- **Wrong mnemonic.** Re-pair the device with a different mnemonic, reload host page; auto-restore should detect the pubkey mismatch and force logout.
|
||||
- **NIP-04 / NIP-44 round-trip.** Encrypt to self, decrypt, verify equality.
|
||||
- **Cross-method interaction.** Logout from `nsigner`; switch to `local`; back to `nsigner`. No leaked state in `localStorage`.
|
||||
|
||||
### 8.3 Negative
|
||||
|
||||
- Wrong index → device returns error; UI surfaces it.
|
||||
- Device locked (firmware-side session locked) → `unauthorized` error surfaced.
|
||||
- USB error mid-frame → driver rejects pending; mutex releases; next call works after reconnect.
|
||||
- WebUSB denied at OS level (no udev rule) → friendly Linux hint shown.
|
||||
|
||||
### 8.4 Cross-browser
|
||||
|
||||
- Chrome, Edge: full support.
|
||||
- Firefox, Safari: tile shows disabled state with hint; existing methods (`extension`, `local`, `connect`) remain unaffected.
|
||||
|
||||
## 9. Phased delivery
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
P1[Phase 1: WebUSB driver module + harness] --> P2[Phase 2: Modal tile + facade branches]
|
||||
P2 --> P3[Phase 3: Persistence + auto-restore]
|
||||
P3 --> P4[Phase 4: Disconnect/reconnect UX + Linux udev hint]
|
||||
P4 --> P5[Phase 5: Docs + example page]
|
||||
```
|
||||
|
||||
- **Phase 1 — Driver.** Land [`src/signers/nsigner-webusb.js`](../src/signers/nsigner-webusb.js) plus a standalone harness page mirroring the upstream demo. No changes to the lib's public surface yet. Unit tests (§8.1) green.
|
||||
- **Phase 2 — Modal tile + facade.** New tile, `_setAuthMethod('nsigner', …)`, six facade `case` arms. Manual smoke test from [`examples/sign.html`](../examples/sign.html).
|
||||
- **Phase 3 — Persistence + auto-restore.** `AuthManager` `case 'nsigner'` (persist + restore), `_attemptNSignerRestore()` on init.
|
||||
- **Phase 4 — Disconnect/reconnect.** `nlReconnectionRequired` parity with NIP-46. Friendly Linux udev snippet on first `SecurityError`.
|
||||
- **Phase 5 — Docs.** Update [`README.md`](../README.md:21) `methods.nsigner`, add [`examples/nsigner.html`](../examples/nsigner.html), brief section in [`login_logic.md`](../login_logic.md:1) and a one-line note that `n_signer` users running NIP-46 bunker mode or the future browser extension use the existing `connect` / `extension` tiles respectively.
|
||||
|
||||
## 10. What we explicitly do not change
|
||||
|
||||
- The host page's per-page code — they all reach the new path through `window.nostr`.
|
||||
- The `WindowNostr` facade's public surface — only new `case` arms, no removed/renamed methods.
|
||||
- Existing methods (`extension`, `local`, `seedphrase`, `connect`, `readonly`, `otp`) — completely untouched.
|
||||
- The build pipeline — only [`src/build.js`](../src/build.js:1) and the new [`src/signers/nsigner-webusb.js`](../src/signers/nsigner-webusb.js) feed the bundler.
|
||||
|
||||
## 11. Development workflow & deployment
|
||||
|
||||
### 11.1 WebUSB requires a secure context
|
||||
|
||||
`navigator.usb` is gated to **secure contexts only** ([WebUSB spec](https://wicg.github.io/webusb/)). The host page that loads `nostr-lite.js` must be served from one of:
|
||||
|
||||
- ✅ `https://anything.com` — production case.
|
||||
- ✅ `http://localhost` or `http://127.0.0.1` — browsers treat these as secure for WebUSB.
|
||||
- ✅ `file:///path/to/page.html` — Chrome treats `file://` as secure for WebUSB; viable for local examples / harness pages.
|
||||
- ✅ `https://laantungir.net/...` — confirmed HTTPS, so the deployed copy at `https://laantungir.net/nostr-login-lite/` is WebUSB-eligible.
|
||||
- ❌ Plain `http://` non-localhost origins — `requestDevice()` is undefined or throws. Not a concern in this project since the server is HTTPS.
|
||||
|
||||
Both `localhost` for dev and `https://laantungir.net` for staging/prod are eligible. No constraint blocks the integration.
|
||||
|
||||
### 11.2 Existing deploy pipeline
|
||||
|
||||
[`deploy.sh`](../deploy.sh:1) already pushes built artifacts to the server:
|
||||
|
||||
```bash
|
||||
rsync -avz --chmod=644 --progress build/{nostr-lite.js,nostr.bundle.js} \
|
||||
ubuntu@laantungir.net:html/nostr-login-lite/
|
||||
```
|
||||
|
||||
[`increment_build_push.sh`](../increment_build_push.sh:1) handles version bump → `node src/build.js` → git commit/tag/push. The two scripts are independent; `deploy.sh` is the rsync step.
|
||||
|
||||
### 11.3 Recommended dev workflow for this feature
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Local edit src/build.js + signers/nsigner-webusb.js] --> B[node src/build.js]
|
||||
B --> C{Test target?}
|
||||
C -->|Local| D[Open examples/nsigner.html via http://localhost or file://]
|
||||
C -->|Server| E[deploy.sh rsync to laantungir.net]
|
||||
E --> F[https://laantungir.net/... must be HTTPS for WebUSB]
|
||||
D --> G[Plug in Feather S3, run pair flow]
|
||||
F --> G
|
||||
G --> H[increment_build_push.sh on merge]
|
||||
```
|
||||
|
||||
Concretely:
|
||||
|
||||
1. **Phase 1 / 2 dev iteration on `localhost`.** Run a tiny local static server in the workspace root:
|
||||
```bash
|
||||
python3 -m http.server 8080
|
||||
# then open http://localhost:8080/examples/nsigner.html
|
||||
```
|
||||
`localhost` qualifies as a secure context, so `navigator.usb` works. No HTTPS / certs needed during development.
|
||||
|
||||
2. **Add an example page.** New file [`examples/nsigner.html`](../examples/nsigner.html:1), modeled on [`examples/sign.html`](../examples/sign.html:1) but with `methods: { nsigner: true }` and prominent secure-context detection. This becomes both the local-dev harness and the published test page at `https://laantungir.net/nostr-login-lite/nsigner.html`.
|
||||
|
||||
3. **Update [`deploy.sh`](../deploy.sh:1) to also push the new example page**:
|
||||
```bash
|
||||
rsync -avz --chmod=644 --progress \
|
||||
build/{nostr-lite.js,nostr.bundle.js} \
|
||||
examples/nsigner.html \
|
||||
ubuntu@laantungir.net:html/nostr-login-lite/
|
||||
```
|
||||
|
||||
4. **Server-side validation loop.** After local sign-off:
|
||||
```bash
|
||||
./deploy.sh
|
||||
# then open https://laantungir.net/nostr-login-lite/nsigner.html in Chrome
|
||||
# plug in Feather S3, run the pair flow end-to-end against the deployed bundle
|
||||
```
|
||||
This catches any same-origin / CDN / cache issue that wouldn't surface on `localhost`.
|
||||
|
||||
---
|
||||
|
||||
**Bottom line:** add a thin `NSignerWebUSB` driver, a new `nsigner` method tile (titled "USB Hardware signer"), and six `case 'nsigner'` branches across the `WindowNostr` facade and `AuthManager`. Develop against `http://localhost` (a WebUSB-eligible secure context), deploy through the existing [`deploy.sh`](../deploy.sh:1) once the server origin is confirmed HTTPS. Every existing host page that consumes `window.nostr` becomes USB-hardware-sign-capable with no per-page edits. Other `n_signer` transports (NIP-46 bunker, browser extension) land for free through the lib's existing `connect` and `extension` tiles when upstream ships them.
|
||||
@@ -205,22 +205,44 @@ The following features are planned but not yet implemented:
|
||||
|
||||
## Development
|
||||
|
||||
To work on the source files:
|
||||
⚠️ **CRITICAL: DO NOT EDIT `nostr-lite.js` DIRECTLY!**
|
||||
|
||||
The `nostr-lite.js` file is **auto-generated** by the build script. All changes must be made in the build script itself.
|
||||
|
||||
### Build Process
|
||||
|
||||
```bash
|
||||
# Edit individual components
|
||||
lite/core/nip46-client.js
|
||||
lite/ui/modal.js
|
||||
lite/nostr-login-lite.js
|
||||
# The main library source code is in:
|
||||
lite/build.js # ← Edit this file for library changes
|
||||
|
||||
# Run bundler to create distribution
|
||||
node lite/bundler.js
|
||||
# To make changes:
|
||||
1. Edit lite/build.js # Contains all source code
|
||||
2. cd lite && node build.js # Regenerates nostr-lite.js
|
||||
3. Test your changes in examples/
|
||||
|
||||
# Start dev server (from project root)
|
||||
# NEVER edit these files directly (they get overwritten):
|
||||
lite/nostr-lite.js # ← Auto-generated, don't edit!
|
||||
|
||||
# Separate components that can be edited:
|
||||
lite/ui/modal.js # Modal UI component
|
||||
themes/default/theme.css # Default theme
|
||||
themes/dark/theme.css # Dark theme
|
||||
```
|
||||
|
||||
### Development Workflow
|
||||
|
||||
```bash
|
||||
# 1. Make changes to source
|
||||
nano lite/build.js
|
||||
|
||||
# 2. Rebuild bundle
|
||||
cd lite && node build.js
|
||||
|
||||
# 3. Start dev server (from project root)
|
||||
python3 -m http.server 8000
|
||||
|
||||
# Open test page
|
||||
open http://localhost:8000/examples/simple-demo.html
|
||||
# 4. Test changes
|
||||
open http://localhost:8000/examples/modal.html
|
||||
```
|
||||
|
||||
### Local Bundle Setup
|
||||
@@ -0,0 +1 @@
|
||||
0.1.22
|
||||
+2676
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,489 @@
|
||||
class NSignerWebSerial {
|
||||
// Keep Web Serial chooser unfiltered so users can select any serial port.
|
||||
static FILTERS = [];
|
||||
|
||||
static async requestAndConnect(options = {}) {
|
||||
if (!('serial' in navigator)) {
|
||||
throw new Error('Web Serial API not available in this browser (requires Chrome 89+, Edge 89+, or Brave)');
|
||||
}
|
||||
|
||||
const port = NSignerWebSerial.FILTERS.length
|
||||
? await navigator.serial.requestPort({ filters: NSignerWebSerial.FILTERS })
|
||||
: await navigator.serial.requestPort();
|
||||
const driver = new NSignerWebSerial(port, options);
|
||||
await driver.open();
|
||||
return driver;
|
||||
}
|
||||
|
||||
static async getPairedDevice(options = {}) {
|
||||
if (!('serial' in navigator)) return null;
|
||||
|
||||
const ports = await navigator.serial.getPorts();
|
||||
if (!ports || ports.length === 0) return null;
|
||||
|
||||
const vendorId = options.vendorId ?? null;
|
||||
const productId = options.productId ?? null;
|
||||
|
||||
const match = ports.find(p => {
|
||||
const info = p.getInfo();
|
||||
if (vendorId !== null && info?.usbVendorId !== vendorId) return false;
|
||||
if (productId !== null && info?.usbProductId !== productId) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return match || null;
|
||||
}
|
||||
|
||||
// Identical helpers to NSignerWebUSB — kept here so the class is self-contained.
|
||||
static randomSecretHex() {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return NSignerWebSerial._hex(bytes);
|
||||
}
|
||||
|
||||
static toPubkeyHex(secretHex) {
|
||||
const secret = NSignerWebSerial._hexToBytes(secretHex);
|
||||
const nt = window.NostrTools || {};
|
||||
|
||||
if (nt.schnorr && typeof nt.schnorr.getPublicKey === 'function') {
|
||||
const pub = nt.schnorr.getPublicKey(secret);
|
||||
return typeof pub === 'string' ? pub.toLowerCase() : NSignerWebSerial._hex(pub);
|
||||
}
|
||||
|
||||
if (typeof nt.getPublicKey === 'function') {
|
||||
const pub = nt.getPublicKey(secret);
|
||||
return typeof pub === 'string' ? pub.toLowerCase() : NSignerWebSerial._hex(pub);
|
||||
}
|
||||
|
||||
throw new Error('NostrTools.getPublicKey is unavailable in this bundle');
|
||||
}
|
||||
|
||||
constructor(port, { callerSecretKey, nostrIndex = 0, openStabilizeMs = 1200, signalStrategies = null } = {}) {
|
||||
if (!port) throw new Error('SerialPort is required');
|
||||
if (!callerSecretKey) throw new Error('callerSecretKey is required');
|
||||
|
||||
this._port = port;
|
||||
this.callerSecretKey = callerSecretKey;
|
||||
this.nostrIndex = Number.isFinite(Number(nostrIndex)) ? Number(nostrIndex) : 0;
|
||||
|
||||
this._reader = null;
|
||||
this._readLoopPromise = null;
|
||||
this._ring = new Uint8Array(0);
|
||||
this._pending = new Map(); // id -> { resolve, reject, timer }
|
||||
this._disconnectHandlers = new Set();
|
||||
this._rpcCounter = 0;
|
||||
this._open = false;
|
||||
this._opening = false;
|
||||
this._sawDisconnectDuringOpen = false;
|
||||
this._openStabilizeMs = Math.max(0, Number(openStabilizeMs) || 1200);
|
||||
this._signalStrategies = Array.isArray(signalStrategies) && signalStrategies.length
|
||||
? signalStrategies
|
||||
: [
|
||||
{ dataTerminalReady: false, requestToSend: false, label: 'dtr=0 rts=0' },
|
||||
{ dataTerminalReady: true, requestToSend: true, label: 'dtr=1 rts=1' }
|
||||
];
|
||||
|
||||
// Bound port-level disconnect listener.
|
||||
this._boundPortDisconnect = () => {
|
||||
if (this._opening) this._sawDisconnectDuringOpen = true;
|
||||
this._handleDisconnect();
|
||||
};
|
||||
}
|
||||
|
||||
get isOpen() {
|
||||
return this._open;
|
||||
}
|
||||
|
||||
get vendorId() {
|
||||
return this._port?.getInfo()?.usbVendorId ?? null;
|
||||
}
|
||||
|
||||
get productId() {
|
||||
return this._port?.getInfo()?.usbProductId ?? null;
|
||||
}
|
||||
|
||||
// Web Serial / CH340 / CP2102 do not expose a serial number.
|
||||
get serial() {
|
||||
return null;
|
||||
}
|
||||
|
||||
onDisconnect(cb) {
|
||||
if (typeof cb === 'function') this._disconnectHandlers.add(cb);
|
||||
return () => this._disconnectHandlers.delete(cb);
|
||||
}
|
||||
|
||||
async open() {
|
||||
const originalInfo = this._port?.getInfo?.() || {};
|
||||
let lastError = null;
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
this._opening = true;
|
||||
this._sawDisconnectDuringOpen = false;
|
||||
|
||||
try {
|
||||
if (attempt > 0) {
|
||||
const replacement = await NSignerWebSerial._findReenumeratedPort(originalInfo);
|
||||
if (replacement) this._port = replacement;
|
||||
}
|
||||
|
||||
await this._port.open({
|
||||
baudRate: 115200,
|
||||
dataBits: 8,
|
||||
parity: 'none',
|
||||
stopBits: 1,
|
||||
flowControl: 'none'
|
||||
});
|
||||
|
||||
this._port.addEventListener('disconnect', this._boundPortDisconnect);
|
||||
|
||||
await this._applySignalStrategies();
|
||||
|
||||
// Wait for potential auto-reset / re-enumeration after open+signals.
|
||||
await new Promise(r => setTimeout(r, this._openStabilizeMs));
|
||||
|
||||
if (this._sawDisconnectDuringOpen || !this._port?.readable || !this._port?.writable) {
|
||||
throw new Error('Serial port dropped during open stabilization');
|
||||
}
|
||||
|
||||
this._open = true;
|
||||
this._readLoopPromise = this._readLoop();
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
|
||||
try { this._port.removeEventListener('disconnect', this._boundPortDisconnect); } catch (_) {}
|
||||
try { await this._port.close(); } catch (_) {}
|
||||
|
||||
// Short wait before retrying in case device just re-enumerated.
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
} finally {
|
||||
this._opening = false;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to open n_signer serial port: ${lastError?.message || lastError}`);
|
||||
}
|
||||
|
||||
async close() {
|
||||
this._open = false;
|
||||
|
||||
// Cancel the reader — this breaks out of the read loop.
|
||||
if (this._reader) {
|
||||
try { await this._reader.cancel(); } catch (_) {}
|
||||
}
|
||||
|
||||
// Reject all in-flight RPCs.
|
||||
for (const [, { reject, timer }] of this._pending) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('Connection closed'));
|
||||
}
|
||||
this._pending.clear();
|
||||
|
||||
this._port.removeEventListener('disconnect', this._boundPortDisconnect);
|
||||
|
||||
try { await this._port.close(); } catch (_) {}
|
||||
}
|
||||
|
||||
// ── Public RPC methods (identical surface to NSignerWebUSB) ────────────────
|
||||
|
||||
async getPublicKey() {
|
||||
const params = [{ nostr_index: this.nostrIndex }];
|
||||
const resp = await this._rpcCall('nostr_get_public_key', params);
|
||||
if (!resp || typeof resp.result !== 'string') {
|
||||
throw new Error('Invalid nostr_get_public_key response');
|
||||
}
|
||||
return resp.result.trim().toLowerCase();
|
||||
}
|
||||
|
||||
async signEvent(unsignedEvent) {
|
||||
const params = [unsignedEvent, { nostr_index: this.nostrIndex }];
|
||||
const resp = await this._rpcCall('nostr_sign_event', params);
|
||||
if (!resp || typeof resp.result !== 'object') {
|
||||
throw new Error('Invalid nostr_sign_event response');
|
||||
}
|
||||
return resp.result;
|
||||
}
|
||||
|
||||
async nip04Encrypt(peerHex, plaintext) {
|
||||
const resp = await this._rpcCall('nostr_nip04_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
|
||||
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip04_encrypt response');
|
||||
return resp.result;
|
||||
}
|
||||
|
||||
async nip04Decrypt(peerHex, ciphertext) {
|
||||
const resp = await this._rpcCall('nostr_nip04_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
|
||||
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip04_decrypt response');
|
||||
return resp.result;
|
||||
}
|
||||
|
||||
async nip44Encrypt(peerHex, plaintext) {
|
||||
const resp = await this._rpcCall('nostr_nip44_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
|
||||
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip44_encrypt response');
|
||||
return resp.result;
|
||||
}
|
||||
|
||||
async nip44Decrypt(peerHex, ciphertext) {
|
||||
const resp = await this._rpcCall('nostr_nip44_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
|
||||
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip44_decrypt response');
|
||||
return resp.result;
|
||||
}
|
||||
|
||||
// ── Internal ───────────────────────────────────────────────────────────────
|
||||
|
||||
async _rpcCall(method, params) {
|
||||
const id = `nl-${Date.now()}-${++this._rpcCounter}`;
|
||||
const auth = await this._buildAuth(id, method, params);
|
||||
const req = { jsonrpc: '2.0', id, method, params, auth };
|
||||
|
||||
console.info('NSignerWebSerial _rpcCall outbound:', JSON.stringify(req));
|
||||
|
||||
const resp = await this._sendRpc(req);
|
||||
|
||||
if (resp?.error) {
|
||||
const msg = resp.error?.message || JSON.stringify(resp.error);
|
||||
throw new Error(`n_signer ${method} failed: ${msg}`);
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
_sendRpc(reqObj) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = reqObj.id;
|
||||
const timer = setTimeout(() => {
|
||||
this._pending.delete(id);
|
||||
reject(new Error('Timed out waiting for n_signer response'));
|
||||
}, 30000);
|
||||
|
||||
this._pending.set(id, { resolve, reject, timer });
|
||||
|
||||
this._writeFrame(reqObj).catch(err => {
|
||||
this._pending.delete(id);
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async _writeFrame(reqObj) {
|
||||
const body = NSignerWebSerial._utf8(JSON.stringify(reqObj));
|
||||
const frame = new Uint8Array(4 + body.length);
|
||||
frame.set(NSignerWebSerial._be32(body.length), 0);
|
||||
frame.set(body, 4);
|
||||
|
||||
console.info('NSignerWebSerial _writeFrame:', { payloadBytes: body.length, frameBytes: frame.length });
|
||||
|
||||
// Acquire writer, write, release immediately.
|
||||
const w = this._port.writable.getWriter();
|
||||
try {
|
||||
await w.write(frame);
|
||||
} finally {
|
||||
w.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
async _readLoop() {
|
||||
let ring = new Uint8Array(0);
|
||||
|
||||
try {
|
||||
this._reader = this._port.readable.getReader();
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await this._reader.read();
|
||||
if (done) break;
|
||||
if (!value || value.length === 0) continue;
|
||||
|
||||
// Append chunk to ring buffer.
|
||||
const next = new Uint8Array(ring.length + value.length);
|
||||
next.set(ring, 0);
|
||||
next.set(value, ring.length);
|
||||
ring = next;
|
||||
|
||||
// Parse as many complete frames as possible.
|
||||
while (ring.length >= 4) {
|
||||
const n = (ring[0] << 24) | (ring[1] << 16) | (ring[2] << 8) | ring[3];
|
||||
|
||||
// Invalid length header — slide one byte (boot-log recovery).
|
||||
if (n <= 0 || n > 1_000_000) {
|
||||
ring = ring.slice(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not enough bytes yet for the full payload.
|
||||
if (ring.length < 4 + n) break;
|
||||
|
||||
const payload = ring.slice(4, 4 + n);
|
||||
ring = ring.slice(4 + n);
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = JSON.parse(new TextDecoder().decode(payload));
|
||||
} catch (e) {
|
||||
console.warn('NSignerWebSerial: frame parse error:', e);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.info('NSignerWebSerial _readLoop inbound:', JSON.stringify(resp));
|
||||
|
||||
// Resolve the matching pending RPC.
|
||||
if (resp && resp.id && this._pending.has(resp.id)) {
|
||||
const { resolve, timer } = this._pending.get(resp.id);
|
||||
this._pending.delete(resp.id);
|
||||
clearTimeout(timer);
|
||||
resolve(resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err && err.name !== 'AbortError') {
|
||||
console.warn('NSignerWebSerial read loop error:', err);
|
||||
}
|
||||
} finally {
|
||||
try { this._reader.releaseLock(); } catch (_) {}
|
||||
this._reader = null;
|
||||
this._handleDisconnect();
|
||||
}
|
||||
}
|
||||
|
||||
_handleDisconnect() {
|
||||
if (!this._open && !this._opening) return; // already closed cleanly
|
||||
this._open = false;
|
||||
|
||||
// Reject all in-flight RPCs.
|
||||
for (const [, { reject, timer }] of this._pending) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('n_signer device disconnected'));
|
||||
}
|
||||
this._pending.clear();
|
||||
|
||||
for (const cb of this._disconnectHandlers) {
|
||||
try { cb(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
async _buildAuth(rpcId, method, params) {
|
||||
const callerPriv = NSignerWebSerial._hexToBytes(this.callerSecretKey);
|
||||
const callerPubX = NSignerWebSerial.toPubkeyHex(this.callerSecretKey);
|
||||
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
const paramsJson = JSON.stringify(params ?? null);
|
||||
const bodyHash = await NSignerWebSerial._sha256Hex(NSignerWebSerial._utf8(paramsJson));
|
||||
|
||||
const tags = [
|
||||
['nsigner_rpc', String(rpcId)],
|
||||
['nsigner_method', String(method)],
|
||||
['nsigner_body_hash', bodyHash]
|
||||
];
|
||||
|
||||
const content = 'nostr_login_lite';
|
||||
const nt = window.NostrTools || {};
|
||||
|
||||
// Prefer finalizeEvent() — stable across nostr-tools bundle shapes.
|
||||
if (typeof nt.finalizeEvent === 'function') {
|
||||
const finalized = nt.finalizeEvent({
|
||||
kind: 27235,
|
||||
created_at: createdAt,
|
||||
tags,
|
||||
content
|
||||
}, callerPriv);
|
||||
|
||||
return {
|
||||
id: String(finalized.id || '').toLowerCase(),
|
||||
pubkey: String(finalized.pubkey || callerPubX).toLowerCase(),
|
||||
created_at: createdAt,
|
||||
kind: 27235,
|
||||
tags,
|
||||
content,
|
||||
sig: String(finalized.sig || '').toLowerCase()
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: schnorr.sign directly.
|
||||
const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
|
||||
const id = await NSignerWebSerial._sha256Hex(NSignerWebSerial._utf8(ser));
|
||||
|
||||
if (!nt.schnorr || typeof nt.schnorr.sign !== 'function') {
|
||||
throw new Error('NostrTools signer unavailable (need finalizeEvent or schnorr.sign)');
|
||||
}
|
||||
|
||||
const sigBytes = await nt.schnorr.sign(id, callerPriv, new Uint8Array(32));
|
||||
const sigHex = typeof sigBytes === 'string' ? sigBytes : NSignerWebSerial._hex(sigBytes);
|
||||
|
||||
return { id, pubkey: callerPubX, created_at: createdAt, kind: 27235, tags, content, sig: sigHex };
|
||||
}
|
||||
|
||||
async _applySignalStrategies() {
|
||||
if (!this._port?.setSignals) return;
|
||||
|
||||
for (const strategy of this._signalStrategies) {
|
||||
try {
|
||||
await this._port.setSignals({
|
||||
dataTerminalReady: !!strategy.dataTerminalReady,
|
||||
requestToSend: !!strategy.requestToSend
|
||||
});
|
||||
// Small settle gap between strategies.
|
||||
await new Promise(r => setTimeout(r, 120));
|
||||
} catch (_) {
|
||||
// Ignore unsupported setSignals implementations.
|
||||
}
|
||||
|
||||
if (this._sawDisconnectDuringOpen) return;
|
||||
}
|
||||
}
|
||||
|
||||
static async _findReenumeratedPort(matchInfo = {}) {
|
||||
try {
|
||||
if (!navigator.serial?.getPorts) return null;
|
||||
const ports = await navigator.serial.getPorts();
|
||||
if (!ports?.length) return null;
|
||||
|
||||
const vid = matchInfo?.usbVendorId;
|
||||
const pid = matchInfo?.usbProductId;
|
||||
|
||||
// Prefer exact VID/PID match when available, but do not require it.
|
||||
const exact = ports.find((p) => {
|
||||
const i = p.getInfo?.() || {};
|
||||
if (vid != null && i.usbVendorId !== vid) return false;
|
||||
if (pid != null && i.usbProductId !== pid) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return exact || ports[0] || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Static utilities (mirrors NSignerWebUSB) ───────────────────────────────
|
||||
|
||||
static _utf8(s) {
|
||||
return new TextEncoder().encode(s);
|
||||
}
|
||||
|
||||
static _be32(n) {
|
||||
return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
||||
}
|
||||
|
||||
static _hex(bytes) {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
static _hexToBytes(hex) {
|
||||
const v = String(hex || '').trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/.test(v)) {
|
||||
throw new Error('Secret key must be 64 hex chars');
|
||||
}
|
||||
const out = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) out[i] = parseInt(v.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
static async _sha256Hex(dataBytes) {
|
||||
const h = await crypto.subtle.digest('SHA-256', dataBytes);
|
||||
return NSignerWebSerial._hex(new Uint8Array(h));
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.NSignerWebSerial = NSignerWebSerial;
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
class NSignerWebUSB {
|
||||
// Keep WebUSB chooser unfiltered so users can select any USB device.
|
||||
// WebUSB requires a non-empty filters array; {} matches all devices.
|
||||
static FILTERS = [{}];
|
||||
|
||||
static async requestAndConnect(options = {}) {
|
||||
if (!('usb' in navigator)) {
|
||||
throw new Error('WebUSB not available in this browser');
|
||||
}
|
||||
|
||||
const device = await navigator.usb.requestDevice({ filters: NSignerWebUSB.FILTERS });
|
||||
const driver = new NSignerWebUSB(device, options);
|
||||
await driver.open();
|
||||
return driver;
|
||||
}
|
||||
|
||||
static async getPairedDevice(options = {}) {
|
||||
if (!('usb' in navigator)) return null;
|
||||
|
||||
const devices = await navigator.usb.getDevices();
|
||||
if (!devices || devices.length === 0) return null;
|
||||
|
||||
const vendorId = options.vendorId ?? null;
|
||||
const productId = options.productId ?? null;
|
||||
const serial = options.serialNumber || null;
|
||||
|
||||
return devices.find((d) => {
|
||||
if (vendorId !== null && vendorId !== undefined && d.vendorId !== vendorId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (productId !== null && productId !== undefined && d.productId !== productId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (serial && d.serialNumber) return d.serialNumber === serial;
|
||||
return true;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
static randomSecretHex() {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return NSignerWebUSB._hex(bytes);
|
||||
}
|
||||
|
||||
static toPubkeyHex(secretHex) {
|
||||
const secret = NSignerWebUSB._hexToBytes(secretHex);
|
||||
const nt = window.NostrTools || {};
|
||||
|
||||
if (nt.schnorr && typeof nt.schnorr.getPublicKey === 'function') {
|
||||
const pub = nt.schnorr.getPublicKey(secret);
|
||||
return typeof pub === 'string' ? pub.toLowerCase() : NSignerWebUSB._hex(pub);
|
||||
}
|
||||
|
||||
if (typeof nt.getPublicKey === 'function') {
|
||||
const pub = nt.getPublicKey(secret);
|
||||
return typeof pub === 'string' ? pub.toLowerCase() : NSignerWebUSB._hex(pub);
|
||||
}
|
||||
|
||||
throw new Error('NostrTools.getPublicKey is unavailable in this bundle');
|
||||
}
|
||||
|
||||
constructor(usbDevice, { callerSecretKey, nostrIndex = 0 } = {}) {
|
||||
if (!usbDevice) throw new Error('USB device is required');
|
||||
if (!callerSecretKey) throw new Error('callerSecretKey is required');
|
||||
|
||||
this.device = usbDevice;
|
||||
this.callerSecretKey = callerSecretKey;
|
||||
this.nostrIndex = Number.isFinite(Number(nostrIndex)) ? Number(nostrIndex) : 0;
|
||||
|
||||
this.iface = null;
|
||||
this.epIn = 1;
|
||||
this.epOut = 1;
|
||||
this._busy = false;
|
||||
this._disconnectHandlers = new Set();
|
||||
this._rpcCounter = 0;
|
||||
this._boundDisconnect = (event) => {
|
||||
if (event.device === this.device) {
|
||||
for (const cb of this._disconnectHandlers) {
|
||||
try { cb(event); } catch (_) {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (navigator.usb?.addEventListener) {
|
||||
navigator.usb.addEventListener('disconnect', this._boundDisconnect);
|
||||
}
|
||||
}
|
||||
|
||||
get isOpen() {
|
||||
return !!this.device?.opened;
|
||||
}
|
||||
|
||||
get vendorId() {
|
||||
return this.device?.vendorId;
|
||||
}
|
||||
|
||||
get productId() {
|
||||
return this.device?.productId;
|
||||
}
|
||||
|
||||
get serial() {
|
||||
return this.device?.serialNumber || null;
|
||||
}
|
||||
|
||||
onDisconnect(cb) {
|
||||
if (typeof cb === 'function') this._disconnectHandlers.add(cb);
|
||||
return () => this._disconnectHandlers.delete(cb);
|
||||
}
|
||||
|
||||
async open() {
|
||||
if (!this.device.opened) await this.device.open();
|
||||
if (this.device.configuration === null) await this.device.selectConfiguration(1);
|
||||
|
||||
const intf = this.device.configuration.interfaces.find(i =>
|
||||
i.alternates.some(a => a.interfaceClass === 0xff)
|
||||
);
|
||||
|
||||
if (!intf) throw new Error('No vendor WebUSB interface found');
|
||||
|
||||
this.iface = intf.interfaceNumber;
|
||||
await this.device.claimInterface(this.iface);
|
||||
|
||||
const alt = intf.alternates.find(a => a.interfaceClass === 0xff);
|
||||
if (alt) await this.device.selectAlternateInterface(this.iface, alt.alternateSetting);
|
||||
|
||||
await this.device.controlTransferOut({
|
||||
requestType: 'class',
|
||||
recipient: 'interface',
|
||||
request: 0x22,
|
||||
value: 1,
|
||||
index: this.iface
|
||||
});
|
||||
}
|
||||
|
||||
async close() {
|
||||
try {
|
||||
if (this.device?.opened && this.iface !== null) {
|
||||
await this.device.releaseInterface(this.iface);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
if (this.device?.opened) await this.device.close();
|
||||
} catch (_) {}
|
||||
|
||||
this.iface = null;
|
||||
|
||||
if (navigator.usb?.removeEventListener && this._boundDisconnect) {
|
||||
navigator.usb.removeEventListener('disconnect', this._boundDisconnect);
|
||||
}
|
||||
}
|
||||
|
||||
async getPublicKey() {
|
||||
const params = [{ nostr_index: this.nostrIndex }];
|
||||
const resp = await this._rpcCall('nostr_get_public_key', params);
|
||||
if (!resp || typeof resp.result !== 'string') {
|
||||
throw new Error('Invalid nostr_get_public_key response');
|
||||
}
|
||||
return resp.result.trim().toLowerCase();
|
||||
}
|
||||
|
||||
async signEvent(unsignedEvent) {
|
||||
const params = [unsignedEvent, { nostr_index: this.nostrIndex }];
|
||||
const resp = await this._rpcCall('nostr_sign_event', params);
|
||||
if (!resp || typeof resp.result !== 'object') {
|
||||
throw new Error('Invalid nostr_sign_event response');
|
||||
}
|
||||
return resp.result;
|
||||
}
|
||||
|
||||
async nip04Encrypt(peerHex, plaintext) {
|
||||
const resp = await this._rpcCall('nostr_nip04_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
|
||||
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip04_encrypt response');
|
||||
return resp.result;
|
||||
}
|
||||
|
||||
async nip04Decrypt(peerHex, ciphertext) {
|
||||
const resp = await this._rpcCall('nostr_nip04_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
|
||||
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip04_decrypt response');
|
||||
return resp.result;
|
||||
}
|
||||
|
||||
async nip44Encrypt(peerHex, plaintext) {
|
||||
const resp = await this._rpcCall('nostr_nip44_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
|
||||
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip44_encrypt response');
|
||||
return resp.result;
|
||||
}
|
||||
|
||||
async nip44Decrypt(peerHex, ciphertext) {
|
||||
const resp = await this._rpcCall('nostr_nip44_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
|
||||
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip44_decrypt response');
|
||||
return resp.result;
|
||||
}
|
||||
|
||||
async _rpcCall(method, params) {
|
||||
if (this._busy) {
|
||||
throw new Error('n_signer device is busy; wait for previous request');
|
||||
}
|
||||
|
||||
this._busy = true;
|
||||
try {
|
||||
const id = `nl-${Date.now()}-${++this._rpcCounter}`;
|
||||
const auth = await this._buildAuth(id, method, params);
|
||||
const req = { jsonrpc: '2.0', id, method, params, auth };
|
||||
const resp = await this._sendRpc(req);
|
||||
|
||||
if (resp?.error) {
|
||||
const msg = resp.error?.message || JSON.stringify(resp.error);
|
||||
throw new Error(`n_signer ${method} failed: ${msg}`);
|
||||
}
|
||||
|
||||
return resp;
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _buildAuth(rpcId, method, params) {
|
||||
const callerPriv = NSignerWebUSB._hexToBytes(this.callerSecretKey);
|
||||
const callerPubX = NSignerWebUSB.toPubkeyHex(this.callerSecretKey);
|
||||
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
const paramsJson = JSON.stringify(params ?? null);
|
||||
const bodyHash = await NSignerWebUSB._sha256Hex(NSignerWebUSB._utf8(paramsJson));
|
||||
|
||||
const tags = [
|
||||
['nsigner_rpc', String(rpcId)],
|
||||
['nsigner_method', String(method)],
|
||||
['nsigner_body_hash', bodyHash]
|
||||
];
|
||||
|
||||
const content = 'nostr_login_lite';
|
||||
const nt = window.NostrTools || {};
|
||||
|
||||
// Prefer finalizeEvent() because it is stable across nostr-tools bundle shapes.
|
||||
if (typeof nt.finalizeEvent === 'function') {
|
||||
const finalized = nt.finalizeEvent({
|
||||
kind: 27235,
|
||||
created_at: createdAt,
|
||||
tags,
|
||||
content
|
||||
}, callerPriv);
|
||||
|
||||
return {
|
||||
id: String(finalized.id || '').toLowerCase(),
|
||||
pubkey: String(finalized.pubkey || callerPubX).toLowerCase(),
|
||||
created_at: createdAt,
|
||||
kind: 27235,
|
||||
tags,
|
||||
content,
|
||||
sig: String(finalized.sig || '').toLowerCase()
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback path for bundles exposing schnorr directly.
|
||||
const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
|
||||
const id = await NSignerWebUSB._sha256Hex(NSignerWebUSB._utf8(ser));
|
||||
|
||||
if (!nt.schnorr || typeof nt.schnorr.sign !== 'function') {
|
||||
throw new Error('NostrTools signer unavailable (need finalizeEvent or schnorr.sign)');
|
||||
}
|
||||
|
||||
const sigBytes = await nt.schnorr.sign(id, callerPriv, new Uint8Array(32));
|
||||
const sigHex = typeof sigBytes === 'string' ? sigBytes : NSignerWebUSB._hex(sigBytes);
|
||||
|
||||
return {
|
||||
id,
|
||||
pubkey: callerPubX,
|
||||
created_at: createdAt,
|
||||
kind: 27235,
|
||||
tags,
|
||||
content,
|
||||
sig: sigHex
|
||||
};
|
||||
}
|
||||
|
||||
async _sendRpc(reqObj) {
|
||||
const reqJson = JSON.stringify(reqObj);
|
||||
console.info('NSigner _sendRpc outbound JSON:', reqJson);
|
||||
|
||||
const body = NSignerWebUSB._utf8(reqJson);
|
||||
const frame = new Uint8Array(4 + body.length);
|
||||
frame.set(NSignerWebUSB._be32(body.length), 0);
|
||||
frame.set(body, 4);
|
||||
|
||||
console.info('NSigner _sendRpc transferOut:', {
|
||||
endpoint: this.epOut,
|
||||
payloadBytes: body.length,
|
||||
frameBytes: frame.length
|
||||
});
|
||||
|
||||
await this.device.transferOut(this.epOut, frame);
|
||||
|
||||
const deadline = Date.now() + 30000;
|
||||
let ring = new Uint8Array(0);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const r = await this.device.transferIn(this.epIn, 512);
|
||||
|
||||
if (!r || !r.data || r.data.byteLength === 0) continue;
|
||||
|
||||
const chunk = new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength);
|
||||
const next = new Uint8Array(ring.length + chunk.length);
|
||||
next.set(ring, 0);
|
||||
next.set(chunk, ring.length);
|
||||
ring = next;
|
||||
|
||||
while (ring.length >= 4) {
|
||||
const n = (ring[0] << 24) | (ring[1] << 16) | (ring[2] << 8) | ring[3];
|
||||
if (n <= 0 || n > 1_000_000) {
|
||||
ring = ring.slice(1);
|
||||
continue;
|
||||
}
|
||||
if (ring.length < 4 + n) break;
|
||||
|
||||
const payload = ring.slice(4, 4 + n);
|
||||
ring = ring.slice(4 + n);
|
||||
const txt = new TextDecoder().decode(payload);
|
||||
return JSON.parse(txt);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for n_signer response');
|
||||
}
|
||||
|
||||
static _utf8(s) {
|
||||
return new TextEncoder().encode(s);
|
||||
}
|
||||
|
||||
static _be32(n) {
|
||||
return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
||||
}
|
||||
|
||||
static _hex(bytes) {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
static _hexToBytes(hex) {
|
||||
const v = String(hex || '').trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/.test(v)) {
|
||||
throw new Error('Secret key must be 64 hex chars');
|
||||
}
|
||||
const out = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) out[i] = parseInt(v.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
static async _sha256Hex(dataBytes) {
|
||||
const h = await crypto.subtle.digest('SHA-256', dataBytes);
|
||||
return NSignerWebUSB._hex(new Uint8Array(h));
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.NSignerWebUSB = NSignerWebUSB;
|
||||
}
|
||||
+3045
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,370 @@
|
||||
# NOSTR_LOGIN_LITE Theme System
|
||||
|
||||
A comprehensive theming system supporting CSS custom properties, JSON metadata, and runtime theme switching.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The theme system consists of:
|
||||
|
||||
- **CSS Custom Properties**: Dynamic styling variables
|
||||
- **JSON Metadata**: Theme descriptions and configurations
|
||||
- **Theme Manager**: Runtime loading and switching
|
||||
- **Directory Organization**: Structured theme packages
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
themes/
|
||||
├── README.md # This documentation
|
||||
├── theme-manager.js # Theme management system
|
||||
├── default/ # Default monospace theme
|
||||
│ ├── theme.json # Theme metadata
|
||||
│ ├── theme.css # CSS custom properties
|
||||
│ └── assets/ # Theme assets (fonts, images)
|
||||
├── dark/ # Dark cyberpunk theme
|
||||
│ ├── theme.json
|
||||
│ ├── theme.css
|
||||
│ └── assets/
|
||||
└── community/ # Community contributed themes
|
||||
└── [theme-name]/
|
||||
├── theme.json
|
||||
├── theme.css
|
||||
└── assets/
|
||||
```
|
||||
|
||||
## CSS Custom Properties
|
||||
|
||||
All themes use standardized CSS custom properties with the `--nl-` prefix:
|
||||
|
||||
### Colors
|
||||
- `--nl-primary-color`: Main text/border color
|
||||
- `--nl-secondary-color`: Background color
|
||||
- `--nl-accent-color`: Hover/active accent color
|
||||
|
||||
### Typography
|
||||
- `--nl-font-family`: Base font family
|
||||
- `--nl-font-size-base`: Base font size (14px)
|
||||
- `--nl-font-size-title`: Title font size (24px)
|
||||
- `--nl-font-size-heading`: Heading font size (18px)
|
||||
- `--nl-font-size-button`: Button font size (16px)
|
||||
- `--nl-font-weight-normal`: Normal weight (400)
|
||||
- `--nl-font-weight-medium`: Medium weight (500)
|
||||
- `--nl-font-weight-bold`: Bold weight (600)
|
||||
|
||||
### Layout
|
||||
- `--nl-border-radius`: Border radius (15px)
|
||||
- `--nl-border-width`: Border thickness (3px)
|
||||
- `--nl-border-style`: Border style (solid)
|
||||
- `--nl-padding-button`: Button padding (12px 16px)
|
||||
- `--nl-padding-container`: Container padding (20px 24px)
|
||||
|
||||
### Effects
|
||||
- `--nl-transition-duration`: Animation duration (0.2s)
|
||||
- `--nl-transition-easing`: Animation easing (ease)
|
||||
- `--nl-shadow`: Box shadow effects
|
||||
- `--nl-backdrop-filter`: Backdrop filter effects
|
||||
|
||||
### Component States
|
||||
- `--nl-button-bg`: Button background
|
||||
- `--nl-button-color`: Button text color
|
||||
- `--nl-button-border`: Button border
|
||||
- `--nl-button-hover-border-color`: Button hover border
|
||||
- `--nl-button-active-bg`: Button active background
|
||||
- `--nl-button-active-color`: Button active text
|
||||
|
||||
## Theme Metadata (theme.json)
|
||||
|
||||
Each theme must include a `theme.json` file with the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Theme Display Name",
|
||||
"version": "1.0.0",
|
||||
"author": "Author Name/Email",
|
||||
"description": "Theme description",
|
||||
"preview": "preview.png",
|
||||
"compatibility": "1.0+",
|
||||
"license": "MIT",
|
||||
"variables": {
|
||||
"--nl-primary-color": "#000000",
|
||||
"--nl-secondary-color": "#ffffff",
|
||||
"--nl-accent-color": "#ff0000"
|
||||
},
|
||||
"assets": ["fonts/", "images/"],
|
||||
"tags": ["monospace", "dark", "accessibility"]
|
||||
}
|
||||
```
|
||||
|
||||
### Required Fields
|
||||
- `name`: Human-readable theme name
|
||||
- `version`: Semantic version number
|
||||
- `variables`: CSS custom property values
|
||||
|
||||
### Optional Fields
|
||||
- `author`: Theme creator information
|
||||
- `description`: Theme description
|
||||
- `preview`: Preview image filename
|
||||
- `compatibility`: Minimum library version
|
||||
- `license`: License identifier (MIT, GPL, etc.)
|
||||
- `assets`: Additional asset directories
|
||||
- `tags`: Theme categorization tags
|
||||
|
||||
## Creating a New Theme
|
||||
|
||||
### 1. Create Theme Directory
|
||||
|
||||
```bash
|
||||
mkdir themes/my-theme
|
||||
cd themes/my-theme
|
||||
```
|
||||
|
||||
### 2. Create theme.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Custom Theme",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"description": "A custom theme for NOSTR_LOGIN_LITE",
|
||||
"variables": {
|
||||
"--nl-primary-color": "#your-color",
|
||||
"--nl-secondary-color": "#your-bg-color",
|
||||
"--nl-accent-color": "#your-accent-color",
|
||||
"--nl-font-family": "\"Your Font\", monospace"
|
||||
},
|
||||
"tags": ["custom"]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Create theme.css
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Colors */
|
||||
--nl-primary-color: #your-color;
|
||||
--nl-secondary-color: #your-bg-color;
|
||||
--nl-accent-color: #your-accent-color;
|
||||
|
||||
/* Typography */
|
||||
--nl-font-family: "Your Font", monospace;
|
||||
|
||||
/* Layout - inherit defaults or customize */
|
||||
--nl-border-radius: 15px;
|
||||
--nl-border-width: 3px;
|
||||
|
||||
/* Add custom variables */
|
||||
--nl-custom-shadow: 0 0 10px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* Optional: Custom component styles */
|
||||
.nl-button {
|
||||
/* Theme-specific enhancements */
|
||||
box-shadow: var(--nl-custom-shadow);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Add Assets (Optional)
|
||||
|
||||
```
|
||||
my-theme/
|
||||
├── theme.json
|
||||
├── theme.css
|
||||
└── assets/
|
||||
├── fonts/
|
||||
│ └── custom-font.woff2
|
||||
└── images/
|
||||
└── pattern.png
|
||||
```
|
||||
|
||||
### 5. Register Theme
|
||||
|
||||
Update `theme-manager.js` to include your theme:
|
||||
|
||||
```javascript
|
||||
this.availableThemes.set('my-theme', {
|
||||
name: 'My Custom Theme',
|
||||
path: 'my-theme',
|
||||
description: 'A custom theme for NOSTR_LOGIN_LITE'
|
||||
});
|
||||
```
|
||||
|
||||
## Using Themes
|
||||
|
||||
### Initialization
|
||||
|
||||
```javascript
|
||||
await NOSTR_LOGIN_LITE.init({
|
||||
theme: 'default',
|
||||
themePath: './themes/'
|
||||
});
|
||||
```
|
||||
|
||||
### Runtime Switching
|
||||
|
||||
```javascript
|
||||
// Switch theme
|
||||
await NOSTR_LOGIN_LITE.switchTheme('dark');
|
||||
|
||||
// Get current theme
|
||||
const current = NOSTR_LOGIN_LITE.getCurrentTheme();
|
||||
|
||||
// List available themes
|
||||
const available = NOSTR_LOGIN_LITE.getAvailableThemes();
|
||||
```
|
||||
|
||||
### Custom Variables
|
||||
|
||||
```javascript
|
||||
// Set custom variable
|
||||
NOSTR_LOGIN_LITE.setThemeVariable('--nl-accent-color', '#ff00ff');
|
||||
|
||||
// Get variable value
|
||||
const value = NOSTR_LOGIN_LITE.getThemeVariable('--nl-accent-color');
|
||||
```
|
||||
|
||||
### Theme Export
|
||||
|
||||
```javascript
|
||||
// Export current theme configuration
|
||||
const themeData = NOSTR_LOGIN_LITE.exportTheme();
|
||||
console.log(JSON.stringify(themeData, null, 2));
|
||||
```
|
||||
|
||||
## Theme Guidelines
|
||||
|
||||
### Accessibility
|
||||
- Ensure sufficient color contrast (4.5:1 minimum)
|
||||
- Test with screen readers
|
||||
- Support high contrast mode
|
||||
- Use semantic color names
|
||||
|
||||
### Performance
|
||||
- Minimize CSS file size
|
||||
- Optimize asset files
|
||||
- Use web-safe fonts as fallbacks
|
||||
- Consider loading performance
|
||||
|
||||
### Compatibility
|
||||
- Test across browsers
|
||||
- Ensure mobile responsiveness
|
||||
- Validate CSS custom property support
|
||||
- Test with different font sizes
|
||||
|
||||
### Best Practices
|
||||
- Use consistent naming conventions
|
||||
- Provide clear documentation
|
||||
- Include preview images
|
||||
- Tag themes appropriately
|
||||
- Test thoroughly before submission
|
||||
|
||||
## Community Contributions
|
||||
|
||||
### Submission Process
|
||||
1. Fork the repository
|
||||
2. Create theme in `themes/community/your-theme/`
|
||||
3. Follow all guidelines above
|
||||
4. Test thoroughly
|
||||
5. Submit pull request with:
|
||||
- Theme files
|
||||
- Preview screenshot
|
||||
- Documentation updates
|
||||
|
||||
### Review Criteria
|
||||
- Code quality and organization
|
||||
- Accessibility compliance
|
||||
- Cross-browser compatibility
|
||||
- Unique design contribution
|
||||
- Proper documentation
|
||||
|
||||
## Built-in Themes
|
||||
|
||||
### Default Theme
|
||||
- **Colors**: Black/white/red
|
||||
- **Typography**: Courier New monospace
|
||||
- **Style**: Clean, minimalist, accessible
|
||||
- **Use Case**: General purpose, high readability
|
||||
|
||||
### Dark Theme
|
||||
- **Colors**: Green/black/magenta
|
||||
- **Typography**: Courier New monospace
|
||||
- **Style**: Cyberpunk, terminal-inspired
|
||||
- **Use Case**: Low light environments, developer aesthetic
|
||||
|
||||
## API Reference
|
||||
|
||||
### ThemeManager Class
|
||||
|
||||
```javascript
|
||||
const themeManager = new NostrThemeManager();
|
||||
|
||||
// Load theme
|
||||
await themeManager.loadTheme('theme-name');
|
||||
|
||||
// Switch theme
|
||||
await themeManager.switchTheme('theme-name');
|
||||
|
||||
// Get available themes
|
||||
const themes = themeManager.getAvailableThemes();
|
||||
|
||||
// Set/get variables
|
||||
themeManager.setThemeVariable('--nl-accent-color', '#ff0000');
|
||||
const value = themeManager.getThemeVariable('--nl-accent-color');
|
||||
|
||||
// Export current theme
|
||||
const exported = themeManager.exportCurrentTheme();
|
||||
```
|
||||
|
||||
### NOSTR_LOGIN_LITE Integration
|
||||
|
||||
```javascript
|
||||
// Initialize with theme
|
||||
await NOSTR_LOGIN_LITE.init({ theme: 'dark' });
|
||||
|
||||
// Theme management
|
||||
await NOSTR_LOGIN_LITE.switchTheme('theme-name');
|
||||
const current = NOSTR_LOGIN_LITE.getCurrentTheme();
|
||||
const available = NOSTR_LOGIN_LITE.getAvailableThemes();
|
||||
|
||||
// Variable management
|
||||
NOSTR_LOGIN_LITE.setThemeVariable('--nl-primary-color', '#000000');
|
||||
const color = NOSTR_LOGIN_LITE.getThemeVariable('--nl-primary-color');
|
||||
|
||||
// Export functionality
|
||||
const themeData = NOSTR_LOGIN_LITE.exportTheme();
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
The theme system dispatches events for integration:
|
||||
|
||||
```javascript
|
||||
// Theme change event
|
||||
window.addEventListener('nlThemeChanged', (event) => {
|
||||
console.log('New theme:', event.detail.theme);
|
||||
console.log('Theme data:', event.detail.data);
|
||||
});
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Theme Not Loading
|
||||
- Check theme.json syntax
|
||||
- Verify file paths
|
||||
- Check browser console for errors
|
||||
- Ensure CSS custom properties are supported
|
||||
|
||||
### Variables Not Applying
|
||||
- Verify CSS custom property names (--nl- prefix)
|
||||
- Check CSS specificity
|
||||
- Ensure theme CSS is loaded after base styles
|
||||
- Validate variable values
|
||||
|
||||
### Performance Issues
|
||||
- Optimize CSS file size
|
||||
- Compress assets
|
||||
- Use efficient selectors
|
||||
- Consider lazy loading for large themes
|
||||
|
||||
## License
|
||||
|
||||
The theme system is open source under the MIT license. Individual themes may have their own licenses as specified in their theme.json files.
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* NOSTR_LOGIN_LITE - Dark Monospace Theme
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Core Variables (6) */
|
||||
--nl-primary-color: #white;
|
||||
--nl-secondary-color: #black;
|
||||
--nl-accent-color: #ff0000;
|
||||
--nl-muted-color: #666666;
|
||||
--nl-font-family: "Courier New", Courier, monospace;
|
||||
--nl-border-radius: 15px;
|
||||
--nl-border-width: 3px;
|
||||
|
||||
/* Floating Tab Variables (8) */
|
||||
--nl-tab-bg-logged-out: #ffffff;
|
||||
--nl-tab-bg-logged-in: #000000;
|
||||
--nl-tab-bg-opacity-logged-out: 0.9;
|
||||
--nl-tab-bg-opacity-logged-in: 0.8;
|
||||
--nl-tab-color-logged-out: #000000;
|
||||
--nl-tab-color-logged-in: #ffffff;
|
||||
--nl-tab-border-logged-out: #000000;
|
||||
--nl-tab-border-logged-in: #ff0000;
|
||||
--nl-tab-border-opacity-logged-out: 1.0;
|
||||
--nl-tab-border-opacity-logged-in: 0.9;
|
||||
}
|
||||
|
||||
/* Base component styles using simplified variables */
|
||||
.nl-component {
|
||||
font-family: var(--nl-font-family);
|
||||
color: var(--nl-primary-color);
|
||||
}
|
||||
|
||||
.nl-button {
|
||||
background: var(--nl-secondary-color);
|
||||
color: var(--nl-primary-color);
|
||||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||||
border-radius: var(--nl-border-radius);
|
||||
font-family: var(--nl-font-family);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nl-button:hover {
|
||||
border-color: var(--nl-accent-color);
|
||||
}
|
||||
|
||||
.nl-button:active {
|
||||
background: var(--nl-accent-color);
|
||||
color: var(--nl-secondary-color);
|
||||
}
|
||||
|
||||
.nl-input {
|
||||
background: var(--nl-secondary-color);
|
||||
color: var(--nl-primary-color);
|
||||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||||
border-radius: var(--nl-border-radius);
|
||||
font-family: var(--nl-font-family);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nl-input:focus {
|
||||
border-color: var(--nl-accent-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nl-container {
|
||||
background: var(--nl-secondary-color);
|
||||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||||
border-radius: var(--nl-border-radius);
|
||||
}
|
||||
|
||||
.nl-title, .nl-heading {
|
||||
font-family: var(--nl-font-family);
|
||||
color: var(--nl-primary-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nl-text {
|
||||
font-family: var(--nl-font-family);
|
||||
color: var(--nl-primary-color);
|
||||
}
|
||||
|
||||
.nl-text--muted {
|
||||
color: var(--nl-muted-color);
|
||||
}
|
||||
|
||||
.nl-icon {
|
||||
font-family: var(--nl-font-family);
|
||||
color: var(--nl-primary-color);
|
||||
}
|
||||
|
||||
/* Floating tab styles */
|
||||
.nl-floating-tab {
|
||||
font-family: var(--nl-font-family);
|
||||
border-radius: var(--nl-border-radius);
|
||||
border: var(--nl-border-width) solid;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nl-floating-tab--logged-out {
|
||||
background: rgba(255, 255, 255, var(--nl-tab-bg-opacity-logged-out));
|
||||
color: var(--nl-tab-color-logged-out);
|
||||
border-color: rgba(0, 0, 0, var(--nl-tab-border-opacity-logged-out));
|
||||
}
|
||||
|
||||
.nl-floating-tab--logged-in {
|
||||
background: rgba(0, 0, 0, var(--nl-tab-bg-opacity-logged-in));
|
||||
color: var(--nl-tab-color-logged-in);
|
||||
border-color: rgba(255, 0, 0, var(--nl-tab-border-opacity-logged-in));
|
||||
}
|
||||
|
||||
.nl-transition {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* NOSTR_LOGIN_LITE - Default Monospace Theme
|
||||
* Black/white/red color scheme with monospace typography
|
||||
* Simplified 14-variable system (6 core + 8 floating tab)
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Core Variables (6) */
|
||||
--nl-primary-color: #000000;
|
||||
--nl-secondary-color: #ffffff;
|
||||
--nl-accent-color: #ff0000;
|
||||
--nl-muted-color: #CCCCCC;
|
||||
--nl-font-family: "Courier New", Courier, monospace;
|
||||
--nl-border-radius: 15px;
|
||||
--nl-border-width: 3px;
|
||||
|
||||
/* Floating Tab Variables (8) */
|
||||
--nl-tab-bg-logged-out: #ffffff;
|
||||
--nl-tab-bg-logged-in: #ffffff;
|
||||
--nl-tab-bg-opacity-logged-out: 0.9;
|
||||
--nl-tab-bg-opacity-logged-in: 0.2;
|
||||
--nl-tab-color-logged-out: #000000;
|
||||
--nl-tab-color-logged-in: #ffffff;
|
||||
--nl-tab-border-logged-out: #000000;
|
||||
--nl-tab-border-logged-in: #ff0000;
|
||||
--nl-tab-border-opacity-logged-out: 1.0;
|
||||
--nl-tab-border-opacity-logged-in: 0.1;
|
||||
}
|
||||
|
||||
/* Base component styles using simplified variables */
|
||||
.nl-component {
|
||||
font-family: var(--nl-font-family);
|
||||
color: var(--nl-primary-color);
|
||||
}
|
||||
|
||||
.nl-button {
|
||||
background: var(--nl-secondary-color);
|
||||
color: var(--nl-primary-color);
|
||||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||||
border-radius: var(--nl-border-radius);
|
||||
font-family: var(--nl-font-family);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nl-button:hover {
|
||||
border-color: var(--nl-accent-color);
|
||||
}
|
||||
|
||||
.nl-button:active {
|
||||
background: var(--nl-accent-color);
|
||||
color: var(--nl-secondary-color);
|
||||
}
|
||||
|
||||
.nl-input {
|
||||
background: var(--nl-secondary-color);
|
||||
color: var(--nl-primary-color);
|
||||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||||
border-radius: var(--nl-border-radius);
|
||||
font-family: var(--nl-font-family);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nl-input:focus {
|
||||
border-color: var(--nl-accent-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nl-container {
|
||||
background: var(--nl-secondary-color);
|
||||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||||
border-radius: var(--nl-border-radius);
|
||||
}
|
||||
|
||||
.nl-title, .nl-heading {
|
||||
font-family: var(--nl-font-family);
|
||||
color: var(--nl-primary-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nl-text {
|
||||
font-family: var(--nl-font-family);
|
||||
color: var(--nl-primary-color);
|
||||
}
|
||||
|
||||
.nl-text--muted {
|
||||
color: var(--nl-muted-color);
|
||||
}
|
||||
|
||||
.nl-icon {
|
||||
font-family: var(--nl-font-family);
|
||||
color: var(--nl-primary-color);
|
||||
}
|
||||
|
||||
/* Floating tab styles */
|
||||
.nl-floating-tab {
|
||||
font-family: var(--nl-font-family);
|
||||
border-radius: var(--nl-border-radius);
|
||||
border: var(--nl-border-width) solid;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nl-floating-tab--logged-out {
|
||||
background: rgba(255, 255, 255, var(--nl-tab-bg-opacity-logged-out));
|
||||
color: var(--nl-tab-color-logged-out);
|
||||
border-color: rgba(0, 0, 0, var(--nl-tab-border-opacity-logged-out));
|
||||
}
|
||||
|
||||
.nl-floating-tab--logged-in {
|
||||
background: rgba(0, 0, 0, var(--nl-tab-bg-opacity-logged-in));
|
||||
color: var(--nl-tab-color-logged-in);
|
||||
border-color: rgba(255, 0, 0, var(--nl-tab-border-opacity-logged-in));
|
||||
}
|
||||
|
||||
.nl-transition {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"themes": {
|
||||
"default": {
|
||||
"name": "Default Monospace",
|
||||
"path": "default",
|
||||
"description": "Black/white/red monospace theme with rounded buttons",
|
||||
"author": "NOSTR_LOGIN_LITE",
|
||||
"version": "1.0.0",
|
||||
"preview": "default/preview.png",
|
||||
"tags": ["monospace", "minimalist", "accessibility", "default"],
|
||||
"featured": true
|
||||
},
|
||||
"dark": {
|
||||
"name": "Dark Monospace",
|
||||
"path": "dark",
|
||||
"description": "Dark mode with green accents and monospace typography",
|
||||
"author": "NOSTR_LOGIN_LITE",
|
||||
"version": "1.0.0",
|
||||
"preview": "dark/preview.png",
|
||||
"tags": ["dark", "cyberpunk", "monospace", "accessibility"],
|
||||
"featured": true
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"official": ["default", "dark"],
|
||||
"community": [],
|
||||
"experimental": []
|
||||
},
|
||||
"metadata": {
|
||||
"total_themes": 2,
|
||||
"last_updated": "2025-01-14T11:13:00.000Z",
|
||||
"schema_version": "1.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* NOSTR_LOGIN_LITE Theme Manager
|
||||
* Handles theme loading, switching, and CSS custom property management
|
||||
*/
|
||||
|
||||
class NostrThemeManager {
|
||||
constructor() {
|
||||
this.currentTheme = null;
|
||||
this.availableThemes = new Map();
|
||||
this.themeCache = new Map();
|
||||
this.basePath = './themes/';
|
||||
|
||||
// Initialize with default theme
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
// Load available themes index
|
||||
await this.loadThemeIndex();
|
||||
|
||||
// Set default theme if none is set
|
||||
if (!this.currentTheme) {
|
||||
await this.loadTheme('default');
|
||||
}
|
||||
|
||||
console.log('NostrThemeManager: Initialized with themes:', Array.from(this.availableThemes.keys()));
|
||||
} catch (error) {
|
||||
console.error('NostrThemeManager: Initialization failed:', error);
|
||||
this.fallbackToInlineStyles();
|
||||
}
|
||||
}
|
||||
|
||||
async loadThemeIndex() {
|
||||
// For now, we'll manually register available themes
|
||||
// In production, this could fetch from a themes.json index file
|
||||
this.availableThemes.set('default', {
|
||||
name: 'Default Monospace',
|
||||
path: 'default',
|
||||
description: 'Black/white/red monospace theme'
|
||||
});
|
||||
|
||||
this.availableThemes.set('dark', {
|
||||
name: 'Dark Monospace',
|
||||
path: 'dark',
|
||||
description: 'Dark mode with green accents and monospace typography'
|
||||
});
|
||||
|
||||
// Future themes can be registered here or loaded from an index
|
||||
// this.availableThemes.set('cyberpunk', { ... });
|
||||
}
|
||||
|
||||
async loadTheme(themeName) {
|
||||
try {
|
||||
console.log(`NostrThemeManager: Loading theme "${themeName}"`);
|
||||
|
||||
// Check if theme exists
|
||||
if (!this.availableThemes.has(themeName)) {
|
||||
throw new Error(`Theme "${themeName}" not found`);
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if (this.themeCache.has(themeName)) {
|
||||
const cachedTheme = this.themeCache.get(themeName);
|
||||
this.applyTheme(cachedTheme);
|
||||
this.currentTheme = themeName;
|
||||
return cachedTheme;
|
||||
}
|
||||
|
||||
// Load theme metadata
|
||||
const themeInfo = this.availableThemes.get(themeName);
|
||||
const metadataUrl = `${this.basePath}${themeInfo.path}/theme.json`;
|
||||
|
||||
const response = await fetch(metadataUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load theme metadata: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const themeData = await response.json();
|
||||
|
||||
// Validate theme data
|
||||
this.validateThemeData(themeData);
|
||||
|
||||
// Load CSS file
|
||||
await this.loadThemeCSS(themeInfo.path);
|
||||
|
||||
// Cache the theme data
|
||||
this.themeCache.set(themeName, themeData);
|
||||
|
||||
// Apply the theme
|
||||
this.applyTheme(themeData);
|
||||
this.currentTheme = themeName;
|
||||
|
||||
console.log(`NostrThemeManager: Successfully loaded theme "${themeName}"`);
|
||||
|
||||
// Dispatch theme change event
|
||||
this.dispatchThemeChangeEvent(themeName, themeData);
|
||||
|
||||
return themeData;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`NostrThemeManager: Failed to load theme "${themeName}":`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async loadThemeCSS(themePath) {
|
||||
const cssUrl = `${this.basePath}${themePath}/theme.css`;
|
||||
|
||||
// Remove existing theme CSS
|
||||
const existingThemeCSS = document.getElementById('nl-theme-css');
|
||||
if (existingThemeCSS) {
|
||||
existingThemeCSS.remove();
|
||||
}
|
||||
|
||||
// Load new theme CSS
|
||||
const link = document.createElement('link');
|
||||
link.id = 'nl-theme-css';
|
||||
link.rel = 'stylesheet';
|
||||
link.type = 'text/css';
|
||||
link.href = cssUrl;
|
||||
|
||||
// Wait for CSS to load
|
||||
await new Promise((resolve, reject) => {
|
||||
link.onload = resolve;
|
||||
link.onerror = () => reject(new Error(`Failed to load CSS from ${cssUrl}`));
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
applyTheme(themeData) {
|
||||
if (!themeData.variables) {
|
||||
console.warn('NostrThemeManager: Theme data has no variables to apply');
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
// Apply CSS custom properties
|
||||
Object.entries(themeData.variables).forEach(([property, value]) => {
|
||||
root.style.setProperty(property, value);
|
||||
});
|
||||
|
||||
console.log(`NostrThemeManager: Applied ${Object.keys(themeData.variables).length} CSS variables`);
|
||||
}
|
||||
|
||||
validateThemeData(themeData) {
|
||||
const required = ['name', 'version', 'variables'];
|
||||
|
||||
for (const field of required) {
|
||||
if (!themeData[field]) {
|
||||
throw new Error(`Theme validation failed: missing required field "${field}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof themeData.variables !== 'object') {
|
||||
throw new Error('Theme validation failed: variables must be an object');
|
||||
}
|
||||
}
|
||||
|
||||
fallbackToInlineStyles() {
|
||||
console.log('NostrThemeManager: Falling back to inline styles');
|
||||
|
||||
// Apply default theme variables directly
|
||||
const defaultVariables = {
|
||||
'--nl-primary-color': '#000000',
|
||||
'--nl-secondary-color': '#ffffff',
|
||||
'--nl-accent-color': '#ff0000',
|
||||
'--nl-font-family': '"Courier New", Courier, monospace',
|
||||
'--nl-border-radius': '15px',
|
||||
'--nl-border-width': '3px',
|
||||
'--nl-border-style': 'solid',
|
||||
'--nl-padding-button': '12px 16px',
|
||||
'--nl-padding-container': '20px 24px',
|
||||
'--nl-font-size-base': '14px',
|
||||
'--nl-font-size-title': '24px',
|
||||
'--nl-font-size-button': '16px',
|
||||
'--nl-transition-duration': '0.2s'
|
||||
};
|
||||
|
||||
const root = document.documentElement;
|
||||
Object.entries(defaultVariables).forEach(([property, value]) => {
|
||||
root.style.setProperty(property, value);
|
||||
});
|
||||
|
||||
this.currentTheme = 'fallback';
|
||||
}
|
||||
|
||||
dispatchThemeChangeEvent(themeName, themeData) {
|
||||
if (typeof window !== 'undefined') {
|
||||
const event = new CustomEvent('nlThemeChanged', {
|
||||
detail: {
|
||||
theme: themeName,
|
||||
data: themeData,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API methods
|
||||
getCurrentTheme() {
|
||||
return this.currentTheme;
|
||||
}
|
||||
|
||||
getAvailableThemes() {
|
||||
return Array.from(this.availableThemes.keys());
|
||||
}
|
||||
|
||||
getThemeInfo(themeName) {
|
||||
return this.availableThemes.get(themeName);
|
||||
}
|
||||
|
||||
async switchTheme(themeName) {
|
||||
return await this.loadTheme(themeName);
|
||||
}
|
||||
|
||||
getThemeVariable(variableName) {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const root = document.documentElement;
|
||||
const style = getComputedStyle(root);
|
||||
return style.getPropertyValue(variableName);
|
||||
}
|
||||
|
||||
setThemeVariable(variableName, value) {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty(variableName, value);
|
||||
}
|
||||
|
||||
resetTheme() {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Remove all nl- prefixed custom properties
|
||||
const style = getComputedStyle(root);
|
||||
for (let i = 0; i < style.length; i++) {
|
||||
const property = style[i];
|
||||
if (property.startsWith('--nl-')) {
|
||||
root.style.removeProperty(property);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove theme CSS
|
||||
const themeCSS = document.getElementById('nl-theme-css');
|
||||
if (themeCSS) {
|
||||
themeCSS.remove();
|
||||
}
|
||||
|
||||
this.currentTheme = null;
|
||||
}
|
||||
|
||||
// Theme creation utilities (for developers)
|
||||
exportCurrentTheme() {
|
||||
const root = document.documentElement;
|
||||
const style = getComputedStyle(root);
|
||||
const variables = {};
|
||||
|
||||
for (let i = 0; i < style.length; i++) {
|
||||
const property = style[i];
|
||||
if (property.startsWith('--nl-')) {
|
||||
variables[property] = style.getPropertyValue(property);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'Custom Theme',
|
||||
version: '1.0.0',
|
||||
author: 'User',
|
||||
description: 'Exported theme',
|
||||
variables,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in NOSTR_LOGIN_LITE
|
||||
if (typeof window !== 'undefined') {
|
||||
window.NostrThemeManager = NostrThemeManager;
|
||||
console.log('NostrThemeManager: Class available globally');
|
||||
} else {
|
||||
// Node.js environment
|
||||
module.exports = NostrThemeManager;
|
||||
}
|
||||
Reference in New Issue
Block a user