3 Commits

Author SHA1 Message Date
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
18 changed files with 1751 additions and 1514 deletions

211
17.md
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
44.md
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

View File

@@ -7,14 +7,27 @@ Configure persistent floating tab for login/logout:
```javascript ```javascript
await NOSTR_LOGIN_LITE.init({ await NOSTR_LOGIN_LITE.init({
// Set the initial theme (default: 'default')
theme: 'dark', // Choose from 'default' or 'dark'
// Standard configuration options
methods: {
extension: true,
local: true,
readonly: true,
connect: true,
otp: true
},
// Floating tab configuration (now uses theme-aware text icons)
floatingTab: { floatingTab: {
enabled: true, enabled: true,
hPosition: 0.95, // 0.0-1.0 or '95%' from left hPosition: 0.95, // 0.0-1.0 or '95%' from left
vPosition: 0.5, // 0.0-1.0 or '50%' from top vPosition: 0.5, // 0.0-1.0 or '50%' from top
appearance: { appearance: {
style: 'pill', // 'pill', 'square', 'circle', 'minimal' style: 'pill', // 'pill', 'square', 'circle', 'minimal'
theme: 'auto', // 'auto', 'light', 'dark' theme: 'auto', // 'auto' follows main theme
icon: '🔐', icon: '[LOGIN]', // Now uses text-based icons like [LOGIN], [KEY], [NET]
text: 'Login' text: 'Login'
}, },
behavior: { behavior: {
@@ -27,6 +40,14 @@ await NOSTR_LOGIN_LITE.init({
} }
} }
}); });
// After initialization, you can switch themes dynamically:
NOSTR_LOGIN_LITE.switchTheme('dark');
NOSTR_LOGIN_LITE.switchTheme('default');
// Or customize individual theme variables:
NOSTR_LOGIN_LITE.setThemeVariable('--nl-accent-color', '#00ff00');
``` ```
Control methods: Control methods:

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.

75
examples/button.html Normal file
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-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 {
background: #0052a3;
}
</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>
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: false
}
});
document.getElementById('login-button').addEventListener('click', () => {
window.NOSTR_LOGIN_LITE.launch('login');
});
});
</script>
</body>
</html>

View File

@@ -37,6 +37,7 @@
<script> <script>
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
await window.NOSTR_LOGIN_LITE.init({ await window.NOSTR_LOGIN_LITE.init({
theme:'default',
methods: { methods: {
extension: true, extension: true,
local: true, local: true,

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -9,249 +10,33 @@
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0; margin: 0;
padding: 20px; padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: #ffffff;
min-height: 100vh; min-height: 100vh;
color: white; color: #000000;
}
.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;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container">
<h1>🔐 NOSTR_LOGIN_LITE - All Login Methods Test</h1>
<div id="status" class="status logged-out"> <div id="login-section">
⏳ Initializing NOSTR_LOGIN_LITE... <!-- Login UI if needed -->
</div> </div>
<div id="profile-section">
<div id="login-section"> <img id="profile-picture">
<button id="launch-modal" class="button">🚀 Launch Login Modal</button> <div id="profile-pubkey"></div>
<p style="font-size: 14px; opacity: 0.8; margin-top: 10px;"> <div id="profile-name"></div>
Click to open the NOSTR_LOGIN_LITE modal and test all available login methods: <div id="profile-about"></div>
</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> </div>
<!-- Load the official nostr-tools bundle first --> <!-- Load the official nostr-tools bundle first -->
<script src="../lite/nostr.bundle.js"></script> <script src="../lite/nostr.bundle.js"></script>
<!-- Load NOSTR_LOGIN_LITE main library (now includes NIP-46 extension) --> <!-- Load NOSTR_LOGIN_LITE main library (now includes NIP-46 extension) -->
<script src="../lite/nostr-lite.js"></script> <script src="../lite/nostr-lite.js"></script>
<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 // Global variables
let nlLite = null; let nlLite = null;
@@ -260,11 +45,11 @@
// Initialize NOSTR_LOGIN_LITE // Initialize NOSTR_LOGIN_LITE
async function initializeApp() { async function initializeApp() {
log('INFO', 'Initializing NOSTR_LOGIN_LITE...'); // console.log('INFO', 'Initializing NOSTR_LOGIN_LITE...');
try { try {
await window.NOSTR_LOGIN_LITE.init({ await window.NOSTR_LOGIN_LITE.init({
theme: 'dark', theme: 'default',
darkMode: false, darkMode: false,
relays: [relayUrl, 'wss://relay.damus.io'], relays: [relayUrl, 'wss://relay.damus.io'],
methods: { methods: {
@@ -300,137 +85,67 @@
}); });
nlLite = window.NOSTR_LOGIN_LITE; nlLite = window.NOSTR_LOGIN_LITE;
log('SUCCESS', 'NOSTR_LOGIN_LITE initialized successfully'); console.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'; window.addEventListener('nlMethodSelected', handleAuthEvent);
} catch (error) { } catch (error) {
log('ERROR', `Initialization failed: ${error.message}`); console.log('ERROR', `Initialization failed: ${error.message}`);
document.getElementById('status').innerHTML = '❌ Failed to initialize NOSTR_LOGIN_LITE';
document.getElementById('status').className = 'status logged-out';
} }
} }
// 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) { function handleAuthEvent(event) {
const { type, pubkey, method, error } = event.detail; const {pubkey, method, error } = event.detail;
console.log('INFO', `Auth event received: method=${method}`);
log('INFO', `Auth event received: type=${type}, method=${method}`);
if (method && pubkey) {
if (type === 'login' && pubkey) {
userPubkey = pubkey; userPubkey = pubkey;
log('SUCCESS', `Login successful! Method: ${method}, Pubkey: ${pubkey}`); console.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
loadUserProfile(); 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) { } else if (error) {
log('ERROR', `Authentication error: ${error}`); console.log('ERROR', `Authentication error: ${error}`);
document.getElementById('status').innerHTML = '❌ Authentication failed';
document.getElementById('status').className = 'status logged-out';
} }
} }
// Load user profile // Load user profile using nostr-tools pool
async function loadUserProfile() { async function loadUserProfile() {
if (!userPubkey) return; 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-name').textContent = 'Loading profile...';
document.getElementById('profile-pubkey').textContent = userPubkey; document.getElementById('profile-pubkey').textContent = userPubkey;
try { try {
// Simple WebSocket connection to get profile // Create a SimplePool instance
const ws = new WebSocket(relayUrl); const pool = new window.NostrTools.SimplePool();
const relays = [relayUrl, 'wss://relay.laantungir.net'];
// Get profile event (kind 0) for the user using querySync
const events = await pool.querySync(relays, {
kinds: [0],
authors: [userPubkey],
limit: 1
});
ws.onopen = () => { pool.close(relays); // Clean up connections
log('SUCCESS', 'WebSocket connected, requesting profile...');
const req = JSON.stringify([
'REQ',
'profile',
{
kinds: [0],
authors: [userPubkey],
limit: 1
}
]);
ws.send(req);
};
ws.onmessage = (event) => { if (events.length > 0) {
try { console.log('SUCCESS', 'Profile event received');
const message = JSON.parse(event.data); const profile = JSON.parse(events[0].content);
const [type, subscriptionId, eventData] = message; displayProfile(profile);
} else {
if (type === 'EVENT' && eventData && eventData.kind === 0) { console.log('INFO', 'No profile found');
log('SUCCESS', 'Profile event received'); document.getElementById('profile-name').textContent = 'No profile found';
const profile = JSON.parse(eventData.content); document.getElementById('profile-about').textContent = 'User has not set up a profile yet.';
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);
} catch (error) { } 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-name').textContent = 'Error loading profile';
document.getElementById('profile-about').textContent = error.message; document.getElementById('profile-about').textContent = error.message;
} }
@@ -449,33 +164,26 @@
document.getElementById('profile-picture').src = picture; document.getElementById('profile-picture').src = picture;
} }
log('SUCCESS', `Profile displayed: ${name}`); console.log('SUCCESS', `Profile displayed: ${name}`);
} }
// Logout function // Logout function
async function logout() { async function logout() {
log('INFO', 'Logging out...'); console.log('INFO', 'Logging out...');
try { try {
await nlLite.logout(); await nlLite.logout();
log('SUCCESS', 'Logged out successfully'); console.log('SUCCESS', 'Logged out successfully');
} catch (error) { } catch (error) {
log('ERROR', `Logout failed: ${error.message}`); console.log('ERROR', `Logout failed: ${error.message}`);
} }
} }
// Set up event listeners
document.addEventListener('DOMContentLoaded', () => { 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 // Initialize the app
setTimeout(initializeApp, 100); setTimeout(initializeApp, 100);
}); });
</script> </script>
</body> </body>
</html> </html>

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
examples/modal.html Normal file
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: 'dark',
methods: {
extension: true,
local: true,
readonly: true,
connect: true,
remote: true,
otp: true
},
floatingTab: {
enabled: true,
hPosition: 0.7, // 0.0-1.0 or '95%' from left
vPosition: 0.5, // 0.0-1.0 or '50%' from top
appearance: {
style: 'pill', // 'pill', 'square', 'circle', 'minimal'
theme: 'auto', // 'auto' follows main theme
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>

View File

@@ -1093,10 +1093,8 @@ class NostrLite {
// Apply the selected theme (CSS-only) // Apply the selected theme (CSS-only)
this.switchTheme(this.options.theme); this.switchTheme(this.options.theme);
// Set up window.nostr facade if no extension detected // Always set up window.nostr facade to handle multiple extensions properly
if (this.extensionBridge.getExtensionCount() === 0) { this._setupWindowNostrFacade();
this._setupWindowNostrFacade();
}
// Create modal during init (matching original git architecture) // Create modal during init (matching original git architecture)
this.modal = new Modal(this.options); this.modal = new Modal(this.options);
@@ -1115,9 +1113,14 @@ class NostrLite {
} }
_setupWindowNostrFacade() { _setupWindowNostrFacade() {
if (typeof window !== 'undefined' && !window.nostr) { if (typeof window !== 'undefined') {
window.nostr = new WindowNostr(this); // Store existing window.nostr if it exists (from extensions)
console.log('NOSTR_LOGIN_LITE: window.nostr facade installed'); const existingNostr = window.nostr;
// Always install our facade
window.nostr = new WindowNostr(this, existingNostr);
console.log('NOSTR_LOGIN_LITE: window.nostr facade installed',
existingNostr ? '(with extension passthrough)' : '(no existing extension)');
} }
} }
@@ -1231,45 +1234,313 @@ class NostrLite {
} }
} }
// Window.nostr facade for when no extension is available // NIP-07 compliant window.nostr provider
class WindowNostr { class WindowNostr {
constructor(nostrLite) { constructor(nostrLite, existingNostr = null) {
this.nostrLite = nostrLite; this.nostrLite = nostrLite;
this.authState = null;
this.existingNostr = existingNostr;
this.authenticatedExtension = null;
this._setupEventListeners();
} }
_setupEventListeners() {
// Listen for authentication events to store auth state
if (typeof window !== 'undefined') {
window.addEventListener('nlMethodSelected', (event) => {
this.authState = event.detail;
// If extension method, capture the specific extension the user chose
if (event.detail.method === 'extension') {
this.authenticatedExtension = event.detail.extension;
console.log('WindowNostr: Captured authenticated extension:', this.authenticatedExtension?.constructor?.name);
// Re-install our facade to ensure we intercept signEvent calls
// Extensions may overwrite window.nostr after authentication
if (typeof window !== 'undefined') {
console.log('WindowNostr: Re-installing facade after authentication');
window.nostr = this;
}
}
console.log('WindowNostr: Auth state updated:', this.authState?.method);
});
window.addEventListener('nlLogout', () => {
this.authState = null;
this.authenticatedExtension = null;
console.log('WindowNostr: Auth state cleared');
});
}
}
async getPublicKey() { async getPublicKey() {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()'); if (!this.authState) {
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
}
switch (this.authState.method) {
case 'extension':
// Use the captured authenticated extension, not current window.nostr
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
if (!ext) throw new Error('Extension not available');
return await ext.getPublicKey();
case 'local':
case 'nip46':
return this.authState.pubkey;
case 'readonly':
throw new Error('Read-only mode - cannot get public key');
default:
throw new Error(\`Unsupported auth method: \${this.authState.method}\`);
}
} }
async signEvent(event) { async signEvent(event) {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()'); if (!this.authState) {
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
}
if (this.authState.method === 'readonly') {
throw new Error('Read-only mode - cannot sign events');
}
switch (this.authState.method) {
case 'extension':
// Use the captured authenticated extension, not current window.nostr
console.log('WindowNostr: signEvent - authenticatedExtension:', this.authenticatedExtension);
console.log('WindowNostr: signEvent - authState.extension:', this.authState.extension);
console.log('WindowNostr: signEvent - existingNostr:', this.existingNostr);
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
console.log('WindowNostr: signEvent - using extension:', ext);
console.log('WindowNostr: signEvent - extension constructor:', ext?.constructor?.name);
if (!ext) throw new Error('Extension not available');
return await ext.signEvent(event);
case 'local': {
// Use nostr-tools to sign with local secret key
const { nip19, finalizeEvent } = window.NostrTools;
let secretKey;
if (this.authState.secret.startsWith('nsec')) {
const decoded = nip19.decode(this.authState.secret);
secretKey = decoded.data;
} else {
// Convert hex to Uint8Array
secretKey = this._hexToUint8Array(this.authState.secret);
}
return finalizeEvent(event, secretKey);
}
case 'nip46': {
// Use BunkerSigner for NIP-46
if (!this.authState.signer?.bunkerSigner) {
throw new Error('NIP-46 signer not available');
}
return await this.authState.signer.bunkerSigner.signEvent(event);
}
default:
throw new Error(\`Unsupported auth method: \${this.authState.method}\`);
}
} }
async getRelays() { async getRelays() {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()'); // Return configured relays from nostr-lite options
return this.nostrLite.options?.relays || ['wss://relay.damus.io'];
} }
get nip04() { get nip04() {
return { return {
async encrypt(pubkey, plaintext) { encrypt: async (pubkey, plaintext) => {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()'); if (!this.authState) {
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
}
if (this.authState.method === 'readonly') {
throw new Error('Read-only mode - cannot encrypt');
}
switch (this.authState.method) {
case 'extension': {
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
if (!ext) throw new Error('Extension not available');
return await ext.nip04.encrypt(pubkey, plaintext);
}
case 'local': {
const { nip04, nip19 } = window.NostrTools;
let secretKey;
if (this.authState.secret.startsWith('nsec')) {
const decoded = nip19.decode(this.authState.secret);
secretKey = decoded.data;
} else {
secretKey = this._hexToUint8Array(this.authState.secret);
}
return await nip04.encrypt(secretKey, pubkey, plaintext);
}
case 'nip46': {
if (!this.authState.signer?.bunkerSigner) {
throw new Error('NIP-46 signer not available');
}
return await this.authState.signer.bunkerSigner.nip04Encrypt(pubkey, plaintext);
}
default:
throw new Error(\`Unsupported auth method: \${this.authState.method}\`);
}
}, },
async decrypt(pubkey, ciphertext) {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()'); decrypt: async (pubkey, ciphertext) => {
if (!this.authState) {
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
}
if (this.authState.method === 'readonly') {
throw new Error('Read-only mode - cannot decrypt');
}
switch (this.authState.method) {
case 'extension': {
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
if (!ext) throw new Error('Extension not available');
return await ext.nip04.decrypt(pubkey, ciphertext);
}
case 'local': {
const { nip04, nip19 } = window.NostrTools;
let secretKey;
if (this.authState.secret.startsWith('nsec')) {
const decoded = nip19.decode(this.authState.secret);
secretKey = decoded.data;
} else {
secretKey = this._hexToUint8Array(this.authState.secret);
}
return await nip04.decrypt(secretKey, pubkey, ciphertext);
}
case 'nip46': {
if (!this.authState.signer?.bunkerSigner) {
throw new Error('NIP-46 signer not available');
}
return await this.authState.signer.bunkerSigner.nip04Decrypt(pubkey, ciphertext);
}
default:
throw new Error(\`Unsupported auth method: \${this.authState.method}\`);
}
} }
}; };
} }
get nip44() { get nip44() {
return { return {
async encrypt(pubkey, plaintext) { encrypt: async (pubkey, plaintext) => {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()'); if (!this.authState) {
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
}
if (this.authState.method === 'readonly') {
throw new Error('Read-only mode - cannot encrypt');
}
switch (this.authState.method) {
case 'extension': {
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
if (!ext) throw new Error('Extension not available');
return await ext.nip44.encrypt(pubkey, plaintext);
}
case 'local': {
const { nip44, nip19 } = window.NostrTools;
let secretKey;
if (this.authState.secret.startsWith('nsec')) {
const decoded = nip19.decode(this.authState.secret);
secretKey = decoded.data;
} else {
secretKey = this._hexToUint8Array(this.authState.secret);
}
return nip44.encrypt(plaintext, nip44.getConversationKey(secretKey, pubkey));
}
case 'nip46': {
if (!this.authState.signer?.bunkerSigner) {
throw new Error('NIP-46 signer not available');
}
return await this.authState.signer.bunkerSigner.nip44Encrypt(pubkey, plaintext);
}
default:
throw new Error(\`Unsupported auth method: \${this.authState.method}\`);
}
}, },
async decrypt(pubkey, ciphertext) {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()'); decrypt: async (pubkey, ciphertext) => {
if (!this.authState) {
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
}
if (this.authState.method === 'readonly') {
throw new Error('Read-only mode - cannot decrypt');
}
switch (this.authState.method) {
case 'extension': {
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
if (!ext) throw new Error('Extension not available');
return await ext.nip44.decrypt(pubkey, ciphertext);
}
case 'local': {
const { nip44, nip19 } = window.NostrTools;
let secretKey;
if (this.authState.secret.startsWith('nsec')) {
const decoded = nip19.decode(this.authState.secret);
secretKey = decoded.data;
} else {
secretKey = this._hexToUint8Array(this.authState.secret);
}
return nip44.decrypt(ciphertext, nip44.getConversationKey(secretKey, pubkey));
}
case 'nip46': {
if (!this.authState.signer?.bunkerSigner) {
throw new Error('NIP-46 signer not available');
}
return await this.authState.signer.bunkerSigner.nip44Decrypt(pubkey, ciphertext);
}
default:
throw new Error(\`Unsupported auth method: \${this.authState.method}\`);
}
} }
}; };
} }
_hexToUint8Array(hex) {
if (hex.length % 2 !== 0) {
throw new Error('Invalid hex string length');
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
}
} }
// Initialize and export // Initialize and export

View File

@@ -8,7 +8,7 @@
* Two-file architecture: * Two-file architecture:
* 1. Load nostr.bundle.js (official nostr-tools bundle) * 1. Load nostr.bundle.js (official nostr-tools bundle)
* 2. Load nostr-lite.js (this file - NOSTR_LOGIN_LITE library with CSS-only themes) * 2. Load nostr-lite.js (this file - NOSTR_LOGIN_LITE library with CSS-only themes)
* Generated on: 2025-09-14T17:19:15.753Z * Generated on: 2025-09-15T17:48:16.817Z
*/ */
// Verify dependencies are loaded // Verify dependencies are loaded
@@ -550,15 +550,15 @@ const THEME_CSS = {
/* Floating Tab Variables (8) */ /* Floating Tab Variables (8) */
--nl-tab-bg-logged-out: #ffffff; --nl-tab-bg-logged-out: #ffffff;
--nl-tab-bg-logged-in: #000000; --nl-tab-bg-logged-in: #ffffff;
--nl-tab-bg-opacity-logged-out: 0.9; --nl-tab-bg-opacity-logged-out: 0.9;
--nl-tab-bg-opacity-logged-in: 0.8; --nl-tab-bg-opacity-logged-in: 0.2;
--nl-tab-color-logged-out: #000000; --nl-tab-color-logged-out: #000000;
--nl-tab-color-logged-in: #ffffff; --nl-tab-color-logged-in: #ffffff;
--nl-tab-border-logged-out: #000000; --nl-tab-border-logged-out: #000000;
--nl-tab-border-logged-in: #ff0000; --nl-tab-border-logged-in: #ff0000;
--nl-tab-border-opacity-logged-out: 1.0; --nl-tab-border-opacity-logged-out: 1.0;
--nl-tab-border-opacity-logged-in: 0.9; --nl-tab-border-opacity-logged-in: 0.1;
} }
/* Base component styles using simplified variables */ /* Base component styles using simplified variables */
@@ -2401,10 +2401,8 @@ class NostrLite {
// Apply the selected theme (CSS-only) // Apply the selected theme (CSS-only)
this.switchTheme(this.options.theme); this.switchTheme(this.options.theme);
// Set up window.nostr facade if no extension detected // Always set up window.nostr facade to handle multiple extensions properly
if (this.extensionBridge.getExtensionCount() === 0) { this._setupWindowNostrFacade();
this._setupWindowNostrFacade();
}
// Create modal during init (matching original git architecture) // Create modal during init (matching original git architecture)
this.modal = new Modal(this.options); this.modal = new Modal(this.options);
@@ -2423,9 +2421,14 @@ class NostrLite {
} }
_setupWindowNostrFacade() { _setupWindowNostrFacade() {
if (typeof window !== 'undefined' && !window.nostr) { if (typeof window !== 'undefined') {
window.nostr = new WindowNostr(this); // Store existing window.nostr if it exists (from extensions)
console.log('NOSTR_LOGIN_LITE: window.nostr facade installed'); const existingNostr = window.nostr;
// Always install our facade
window.nostr = new WindowNostr(this, existingNostr);
console.log('NOSTR_LOGIN_LITE: window.nostr facade installed',
existingNostr ? '(with extension passthrough)' : '(no existing extension)');
} }
} }
@@ -2539,45 +2542,313 @@ class NostrLite {
} }
} }
// Window.nostr facade for when no extension is available // NIP-07 compliant window.nostr provider
class WindowNostr { class WindowNostr {
constructor(nostrLite) { constructor(nostrLite, existingNostr = null) {
this.nostrLite = nostrLite; this.nostrLite = nostrLite;
this.authState = null;
this.existingNostr = existingNostr;
this.authenticatedExtension = null;
this._setupEventListeners();
} }
_setupEventListeners() {
// Listen for authentication events to store auth state
if (typeof window !== 'undefined') {
window.addEventListener('nlMethodSelected', (event) => {
this.authState = event.detail;
// If extension method, capture the specific extension the user chose
if (event.detail.method === 'extension') {
this.authenticatedExtension = event.detail.extension;
console.log('WindowNostr: Captured authenticated extension:', this.authenticatedExtension?.constructor?.name);
// Re-install our facade to ensure we intercept signEvent calls
// Extensions may overwrite window.nostr after authentication
if (typeof window !== 'undefined') {
console.log('WindowNostr: Re-installing facade after authentication');
window.nostr = this;
}
}
console.log('WindowNostr: Auth state updated:', this.authState?.method);
});
window.addEventListener('nlLogout', () => {
this.authState = null;
this.authenticatedExtension = null;
console.log('WindowNostr: Auth state cleared');
});
}
}
async getPublicKey() { async getPublicKey() {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()'); if (!this.authState) {
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
}
switch (this.authState.method) {
case 'extension':
// Use the captured authenticated extension, not current window.nostr
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
if (!ext) throw new Error('Extension not available');
return await ext.getPublicKey();
case 'local':
case 'nip46':
return this.authState.pubkey;
case 'readonly':
throw new Error('Read-only mode - cannot get public key');
default:
throw new Error(`Unsupported auth method: ${this.authState.method}`);
}
} }
async signEvent(event) { async signEvent(event) {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()'); if (!this.authState) {
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
}
if (this.authState.method === 'readonly') {
throw new Error('Read-only mode - cannot sign events');
}
switch (this.authState.method) {
case 'extension':
// Use the captured authenticated extension, not current window.nostr
console.log('WindowNostr: signEvent - authenticatedExtension:', this.authenticatedExtension);
console.log('WindowNostr: signEvent - authState.extension:', this.authState.extension);
console.log('WindowNostr: signEvent - existingNostr:', this.existingNostr);
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
console.log('WindowNostr: signEvent - using extension:', ext);
console.log('WindowNostr: signEvent - extension constructor:', ext?.constructor?.name);
if (!ext) throw new Error('Extension not available');
return await ext.signEvent(event);
case 'local': {
// Use nostr-tools to sign with local secret key
const { nip19, finalizeEvent } = window.NostrTools;
let secretKey;
if (this.authState.secret.startsWith('nsec')) {
const decoded = nip19.decode(this.authState.secret);
secretKey = decoded.data;
} else {
// Convert hex to Uint8Array
secretKey = this._hexToUint8Array(this.authState.secret);
}
return finalizeEvent(event, secretKey);
}
case 'nip46': {
// Use BunkerSigner for NIP-46
if (!this.authState.signer?.bunkerSigner) {
throw new Error('NIP-46 signer not available');
}
return await this.authState.signer.bunkerSigner.signEvent(event);
}
default:
throw new Error(`Unsupported auth method: ${this.authState.method}`);
}
} }
async getRelays() { async getRelays() {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()'); // Return configured relays from nostr-lite options
return this.nostrLite.options?.relays || ['wss://relay.damus.io'];
} }
get nip04() { get nip04() {
return { return {
async encrypt(pubkey, plaintext) { encrypt: async (pubkey, plaintext) => {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()'); if (!this.authState) {
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
}
if (this.authState.method === 'readonly') {
throw new Error('Read-only mode - cannot encrypt');
}
switch (this.authState.method) {
case 'extension': {
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
if (!ext) throw new Error('Extension not available');
return await ext.nip04.encrypt(pubkey, plaintext);
}
case 'local': {
const { nip04, nip19 } = window.NostrTools;
let secretKey;
if (this.authState.secret.startsWith('nsec')) {
const decoded = nip19.decode(this.authState.secret);
secretKey = decoded.data;
} else {
secretKey = this._hexToUint8Array(this.authState.secret);
}
return await nip04.encrypt(secretKey, pubkey, plaintext);
}
case 'nip46': {
if (!this.authState.signer?.bunkerSigner) {
throw new Error('NIP-46 signer not available');
}
return await this.authState.signer.bunkerSigner.nip04Encrypt(pubkey, plaintext);
}
default:
throw new Error(`Unsupported auth method: ${this.authState.method}`);
}
}, },
async decrypt(pubkey, ciphertext) {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()'); decrypt: async (pubkey, ciphertext) => {
if (!this.authState) {
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
}
if (this.authState.method === 'readonly') {
throw new Error('Read-only mode - cannot decrypt');
}
switch (this.authState.method) {
case 'extension': {
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
if (!ext) throw new Error('Extension not available');
return await ext.nip04.decrypt(pubkey, ciphertext);
}
case 'local': {
const { nip04, nip19 } = window.NostrTools;
let secretKey;
if (this.authState.secret.startsWith('nsec')) {
const decoded = nip19.decode(this.authState.secret);
secretKey = decoded.data;
} else {
secretKey = this._hexToUint8Array(this.authState.secret);
}
return await nip04.decrypt(secretKey, pubkey, ciphertext);
}
case 'nip46': {
if (!this.authState.signer?.bunkerSigner) {
throw new Error('NIP-46 signer not available');
}
return await this.authState.signer.bunkerSigner.nip04Decrypt(pubkey, ciphertext);
}
default:
throw new Error(`Unsupported auth method: ${this.authState.method}`);
}
} }
}; };
} }
get nip44() { get nip44() {
return { return {
async encrypt(pubkey, plaintext) { encrypt: async (pubkey, plaintext) => {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()'); if (!this.authState) {
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
}
if (this.authState.method === 'readonly') {
throw new Error('Read-only mode - cannot encrypt');
}
switch (this.authState.method) {
case 'extension': {
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
if (!ext) throw new Error('Extension not available');
return await ext.nip44.encrypt(pubkey, plaintext);
}
case 'local': {
const { nip44, nip19 } = window.NostrTools;
let secretKey;
if (this.authState.secret.startsWith('nsec')) {
const decoded = nip19.decode(this.authState.secret);
secretKey = decoded.data;
} else {
secretKey = this._hexToUint8Array(this.authState.secret);
}
return nip44.encrypt(plaintext, nip44.getConversationKey(secretKey, pubkey));
}
case 'nip46': {
if (!this.authState.signer?.bunkerSigner) {
throw new Error('NIP-46 signer not available');
}
return await this.authState.signer.bunkerSigner.nip44Encrypt(pubkey, plaintext);
}
default:
throw new Error(`Unsupported auth method: ${this.authState.method}`);
}
}, },
async decrypt(pubkey, ciphertext) {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()'); decrypt: async (pubkey, ciphertext) => {
if (!this.authState) {
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
}
if (this.authState.method === 'readonly') {
throw new Error('Read-only mode - cannot decrypt');
}
switch (this.authState.method) {
case 'extension': {
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
if (!ext) throw new Error('Extension not available');
return await ext.nip44.decrypt(pubkey, ciphertext);
}
case 'local': {
const { nip44, nip19 } = window.NostrTools;
let secretKey;
if (this.authState.secret.startsWith('nsec')) {
const decoded = nip19.decode(this.authState.secret);
secretKey = decoded.data;
} else {
secretKey = this._hexToUint8Array(this.authState.secret);
}
return nip44.decrypt(ciphertext, nip44.getConversationKey(secretKey, pubkey));
}
case 'nip46': {
if (!this.authState.signer?.bunkerSigner) {
throw new Error('NIP-46 signer not available');
}
return await this.authState.signer.bunkerSigner.nip44Decrypt(pubkey, ciphertext);
}
default:
throw new Error(`Unsupported auth method: ${this.authState.method}`);
}
} }
}; };
} }
_hexToUint8Array(hex) {
if (hex.length % 2 !== 0) {
throw new Error('Invalid hex string length');
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
}
} }
// Initialize and export // Initialize and export

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

370
themes/README.md Normal file
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
themes/dark/theme.css Normal file
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
themes/default/theme.css Normal file
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: #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: #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
themes/index.json Normal file
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
themes/theme-manager.js Normal file
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;
}