Compare commits

...
34 Commits
Author SHA1 Message Date
Laan Tungir a20f17ea32 Unify hardware signer UX with serial-first auto-detect and WebUSB fallback; add transport-aware nsigner persistence/restore 2026-05-27 07:25:28 -04:00
Laan Tungir bdc9513e52 Add nsigner account probe diagnostics and fix modal visibility during chooser flow 2026-05-11 07:51:09 -04:00
Laan Tungir 1fd3862324 Add nsigner account chooser, modal sign-out action, and methods all/empty fallback 2026-05-10 18:25:58 -04:00
Laan Tungir 7f1dc00f49 USB hardware signer 2026-05-10 10:33:46 -04:00
Laan Tungir 973025602c USB hardware signer 2026-05-10 10:30:35 -04:00
Laan Tungir 7af85a45a7 Fix NIP-46 signing across pages by auto-reconnecting BunkerSigner 2026-04-04 09:40:23 -04:00
Your Name 71347ea1bf small change 2026-01-28 13:16:03 -04:00
Your Name 58d9b4386e Remove more logs 2025-11-14 14:40:05 -04:00
Your Name cb4f4b2a3c Remove more logs 2025-11-14 14:31:40 -04:00
Your Name 05a5306f86 Reorganize directories 2025-11-14 13:59:08 -04:00
Your Name 98b87de736 Comment out debug prints 2025-11-14 13:45:29 -04:00
Your Name ae6f176f52 Comment out debug prints 2025-11-14 13:32:27 -04:00
Your Name a79277f3ed small stuff 2025-10-01 10:18:10 -04:00
Your Name 521693cfa1 . 2025-09-24 10:50:11 -04:00
Your Name 3109a93163 Fixed issue not recognizing browser extension 2025-09-22 15:37:53 -04:00
Your Name 4505167246 Change key entry 2025-09-21 11:51:33 -04:00
Your Name ea387c0c9f Add automated versioning and deployment system 2025-09-21 11:22:26 -04:00
Your Name a7dceb1156 Fixed persistance issues 2025-09-20 15:33:14 -04:00
Your Name 966d9d0456 documentation changes 2025-09-20 11:08:45 -04:00
Your Name ccff136edb Single Source of Truth Architecture - Complete authentication state management with storage-based getAuthState() as sole authoritative source 2025-09-20 10:39:43 -04:00
Your Name 8f34c2de73 Seem to have most everything working well. Got persistant state after page refresh, and implmented logout call 2025-09-19 16:09:05 -04:00
Your Name ca75df8bb4 Fixed issue with bunker. Made the modal more beautiful. 2025-09-19 12:24:13 -04:00
Your Name c747f1f315 . 2025-09-18 10:18:32 -04:00
Your Name 2a66b5eeec Fixed name display 2025-09-16 18:13:01 -04:00
Your Name fa9688b17e Implement logging in via seed phrase 2025-09-16 15:51:08 -04:00
Your Name a0e18c34d6 Add comprehensive sign.html test and update documentation
- Add examples/sign.html with comprehensive extension compatibility testing
- Update README.md with profile fetching API documentation
- Update .gitignore for better file management
- Update examples/button.html and examples/modal.html with latest features

This completes the single-extension architecture implementation with:
- nos2x compatibility through true single-extension mode
- Method switching between extension/local/NIP-46/readonly
- Enhanced profile fetching for floating tab
- Comprehensive debugging and testing capabilities
2025-09-16 12:40:15 -04:00
Your Name 995c3f526c Removed interference with extensions. Had to go back to only allowing handling single extension. 2025-09-16 11:55:47 -04:00
Your Name 77ea4a8e67 cleaned up visuals 2025-09-15 14:52:21 -04:00
Your Name 12d4810f4c login with exposed api for web page fixed. 2025-09-15 14:24:14 -04:00
Your Name 517974699d browser extension signing fixed 2025-09-15 13:51:41 -04:00
Your Name bac621bbaa button example 2025-09-14 18:51:27 -04:00
Your Name 9f0b0638e5 Added some good examples 2025-09-14 16:15:35 -04:00
Your Name b59bf17372 Fully functioning theme system. Bugs fixed 2025-09-14 13:23:52 -04:00
Your Name 3b1eb7f951 added embedded option 2025-09-13 15:21:18 -04:00
41 changed files with 22985 additions and 5654 deletions
+3 -8
View File
@@ -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/
-211
View File
@@ -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"
}
```
-297
View File
@@ -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
+135
View File
@@ -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).
+6544
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Executable
+6
View File
@@ -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/
-50
View File
@@ -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.
+134
View File
@@ -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>
+737
View File
@@ -0,0 +1,737 @@
<!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 ─────────────────────────────────────────────────────────────────
// CH340 VID:PID 0x1a86:0x7523 (most CYD revisions)
// CP2102 VID:PID 0x10c4:0xea60 (some CYD revisions)
const SERIAL_FILTERS = [
{ usbVendorId: 0x1a86, usbProductId: 0x7523 },
{ usbVendorId: 0x10c4, usbProductId: 0xea60 }
];
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 = await navigator.serial.requestPort({ filters: SERIAL_FILTERS });
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("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("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("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("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("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("nip44_decrypt", params);
nip44DecOutEl.textContent = requireStringResult(resp, "NIP-44 decrypt");
} catch (e) {
nip44DecOutEl.textContent = String(e);
}
});
</script>
</body>
</html>
+58
View File
@@ -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>
+527
View File
@@ -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("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: [{ vendorId: 0x303a }] });
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("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("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("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("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("nip44_decrypt", params, "web-nip44-dec");
nip44DecOutEl.textContent = requireStringResult(resp, "NIP-44 decrypt");
} catch (e) {
nip44DecOutEl.textContent = String(e);
}
});
</script>
</body>
</html>
+252
View File
@@ -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
View File
@@ -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>
-411
View File
@@ -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>
+75
View File
@@ -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>
+106
View File
@@ -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>
+534
View File
@@ -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>
+184
View File
@@ -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>
+107
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-1082
View File
File diff suppressed because it is too large Load Diff
+413
View File
@@ -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.
-139
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
{
"folders": [
{
"path": "."
}
],
"settings": {
"liveServer.settings.port": 5501
}
}
+154
View File
@@ -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 13 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).
+351
View File
@@ -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 (~58 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.
+32 -10
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
0.1.18
+2626
View File
File diff suppressed because it is too large Load Diff
+488
View File
@@ -0,0 +1,488 @@
class NSignerWebSerial {
// CH340 VID:PID 0x1a86:0x7523 (most CYD revisions)
// CP2102 VID:PID 0x10c4:0xea60 (some CYD revisions)
static FILTERS = [
{ usbVendorId: 0x1a86, usbProductId: 0x7523 },
{ usbVendorId: 0x10c4, usbProductId: 0xea60 }
];
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 = await navigator.serial.requestPort({ filters: NSignerWebSerial.FILTERS });
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('get_public_key', params);
if (!resp || typeof resp.result !== 'string') {
throw new Error('Invalid get_public_key response');
}
return resp.result.trim().toLowerCase();
}
async signEvent(unsignedEvent) {
const params = [unsignedEvent, { nostr_index: this.nostrIndex }];
const resp = await this._rpcCall('sign_event', params);
if (!resp || typeof resp.result !== 'object') {
throw new Error('Invalid sign_event response');
}
return resp.result;
}
async nip04Encrypt(peerHex, plaintext) {
const resp = await this._rpcCall('nip04_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip04_encrypt response');
return resp.result;
}
async nip04Decrypt(peerHex, ciphertext) {
const resp = await this._rpcCall('nip04_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip04_decrypt response');
return resp.result;
}
async nip44Encrypt(peerHex, plaintext) {
const resp = await this._rpcCall('nip44_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip44_encrypt response');
return resp.result;
}
async nip44Decrypt(peerHex, ciphertext) {
const resp = await this._rpcCall('nip44_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid 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;
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;
}
}
// ── 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;
}
+357
View File
@@ -0,0 +1,357 @@
class NSignerWebUSB {
// Keep picker broad (vendor-level) so Chromium can show all matching Feather states.
// Product-level checks still happen during open()/RPC.
static FILTERS = [{ vendorId: 0x303a }];
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 ?? 0x303a;
const productId = options.productId ?? null;
const serial = options.serialNumber || null;
return devices.find((d) => {
const vendorMatch = d.vendorId === vendorId;
if (!vendorMatch) 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('get_public_key', params);
if (!resp || typeof resp.result !== 'string') {
throw new Error('Invalid get_public_key response');
}
return resp.result.trim().toLowerCase();
}
async signEvent(unsignedEvent) {
const params = [unsignedEvent, { nostr_index: this.nostrIndex }];
const resp = await this._rpcCall('sign_event', params);
if (!resp || typeof resp.result !== 'object') {
throw new Error('Invalid sign_event response');
}
return resp.result;
}
async nip04Encrypt(peerHex, plaintext) {
const resp = await this._rpcCall('nip04_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip04_encrypt response');
return resp.result;
}
async nip04Decrypt(peerHex, ciphertext) {
const resp = await this._rpcCall('nip04_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip04_decrypt response');
return resp.result;
}
async nip44Encrypt(peerHex, plaintext) {
const resp = await this._rpcCall('nip44_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip44_encrypt response');
return resp.result;
}
async nip44Decrypt(peerHex, ciphertext) {
const resp = await this._rpcCall('nip44_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid 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;
}
+3048
View File
File diff suppressed because it is too large Load Diff
+370
View File
@@ -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.
+115
View File
@@ -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;
}
+117
View File
@@ -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;
}
+35
View File
@@ -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"
}
}
+286
View File
@@ -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;
}