First Upload
66
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Changelog – OpenNIT Vault (Browser-Erweiterung)
|
||||
|
||||
Format nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
|
||||
Die Erweiterungsversion (`manifest.json`) ist unabhängig von der OpenNIT-Serverversion.
|
||||
|
||||
## [2.4.0] - 2026-07-01
|
||||
|
||||
### Hinzugefügt
|
||||
- **SSO-Anmeldung (OAuth 2.0 + PKCE):** „Mit OpenNIT anmelden" – Anmeldung wie an OpenNIT
|
||||
(lokal + 2FA / Microsoft 365 / Keycloak) über `chrome.identity`. Die Erweiterung erhält kurzlebige
|
||||
Access-Tokens und einen langlebigen, **rotierenden** Refresh-Token; der Zugang wird automatisch erneuert.
|
||||
„Abmelden" widerruft die Sitzung serverseitig.
|
||||
- Der bisherige **manuelle Token** bleibt als „Erweitert"-Option erhalten (Kiosk/Headless).
|
||||
|
||||
### Sicherheit
|
||||
- Kurzlebige Access-Tokens + Refresh-Token-**Rotation mit Reuse-Detection** (bei Wiederverwendung eines
|
||||
bereits rotierten Tokens wird die gesamte Sitzungskette widerrufen).
|
||||
|
||||
## [2.3.0] - 2026-07-01
|
||||
|
||||
### Sicherheit
|
||||
- **Token-Härtung:** Bei aktivem Tresor-PIN wird die PIN-Sperre nun **serverseitig erzwungen** – die
|
||||
Endpunkte für Einträge/Passwort/TOTP liefern erst nach frischer PIN-Entsperrung Daten. Ein gestohlener
|
||||
Token allein ist damit wertlos, solange keine gültige Entsperrung vorliegt (neuer `POST /unlock`
|
||||
mit Fensterdauer, `POST /lock` zum sofortigen Sperren).
|
||||
- **HTTPS-Zwang:** In den Einstellungen werden nur noch `https://`-Adressen akzeptiert (Ausnahme:
|
||||
`localhost`) – verhindert Klartext-Übertragung von Token und Passwörtern.
|
||||
- **Warnung bei fremder Domain:** „Auf dieser Seite ausfüllen" warnt, wenn die aktive Seite nicht zur
|
||||
hinterlegten Adresse des Eintrags passt.
|
||||
|
||||
### Geändert
|
||||
- Die Einstellung „PIN-Sperre" legt nur noch die **Dauer** der Entsperrung fest (die Option „Aus" entfällt,
|
||||
da die Sperre bei gesetztem Tresor-PIN serverseitig gilt).
|
||||
|
||||
## [2.2.2] - 2026-07-01
|
||||
|
||||
### Geändert
|
||||
- Fester Name **„OpenNIT Vault"** (unabhängig vom Instanznamen).
|
||||
- Berechtigung **`tabs` entfernt** – die aktive Tab-Adresse ist bereits durch die Website-Berechtigungen
|
||||
abgedeckt; die Warnung „Browserverlauf lesen" entfällt.
|
||||
|
||||
## [2.2.0] - 2026-07-01
|
||||
|
||||
### Hinzugefügt
|
||||
- **PIN-Sperre** mit demselben PIN wie der Web-Tresor, konfigurierbare Sperrdauer (5 Min / 15 Min /
|
||||
1 Std / bis der Browser geschlossen wird).
|
||||
- **Passwort-Generator** und Anzeigen-Auge beim Anlegen.
|
||||
- **Notizen** in der Detailansicht (anzeigen/kopieren) und in der Suche.
|
||||
- **Zwischenablage-Auto-Clear** nach dem Kopieren (abschaltbar).
|
||||
- **Tastatur-Navigation** in der Liste und **Dark Mode**.
|
||||
|
||||
## [2.1.0] - 2026-06-30
|
||||
|
||||
### Hinzugefügt
|
||||
- **Detailansicht** je Eintrag mit Anzeigen/Kopieren von Benutzername und Passwort sowie 2FA-Code.
|
||||
- **Favicons** der hinterlegten Seiten (serverseitig gecacht).
|
||||
|
||||
### Behoben
|
||||
- Klick auf einen Eintrag öffnet nun die Detailansicht.
|
||||
- Vorschlags-Dropdown an das Design der Erweiterung angeglichen.
|
||||
|
||||
## [2.0.0]
|
||||
|
||||
### Hinzugefügt
|
||||
- Erstveröffentlichung: Autofill für Benutzer-, Passwort- und 2FA-Felder, Popup mit Liste/Suche,
|
||||
Anlegen neuer Einträge, Einstellungen für Server-URL und Token.
|
||||
662
LICENSE
Normal file
|
|
@ -0,0 +1,662 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
|
||||
Copyright (C) {{ year }} {{ organization }}
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
99
PRIVACY.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# Datenschutzerklärung – OpenNIT Vault (Browser-Erweiterung)
|
||||
|
||||
_Stand: 2026-07-01 · Version 2.2.2_
|
||||
|
||||
> Kurzfassung: Die Erweiterung sendet Daten **ausschließlich** an die von dir konfigurierte
|
||||
> OpenNIT-Instanz. Es gibt **keine** Telemetrie, **keine** Analyse-/Tracking-Dienste, **keine**
|
||||
> Drittanbieter-Server und **keinen** Verkauf oder Weitergabe von Daten.
|
||||
|
||||
## 1. Verantwortlicher
|
||||
|
||||
Betreiber der Erweiterung ist der Betreiber der jeweiligen **OpenNIT-Instanz**, mit der sich die
|
||||
Erweiterung verbindet (in der Regel dein Arbeitgeber oder du selbst). Die Erweiterung selbst ist ein
|
||||
technisches Client-Werkzeug ohne eigenen Server.
|
||||
|
||||
## 2. Welche Daten verarbeitet die Erweiterung?
|
||||
|
||||
| Datum | Zweck | Speicherort |
|
||||
|-------|-------|-------------|
|
||||
| **Server-URL** | Adresse deiner OpenNIT-Instanz | lokal (`chrome.storage.local`) |
|
||||
| **API-Token** | Authentifizierung gegenüber deiner Instanz | lokal (`chrome.storage.local`) |
|
||||
| **Einstellungen** (PIN-Sperrdauer, Zwischenablage-Option) | Verhalten der Erweiterung | lokal (`chrome.storage.local`) |
|
||||
| **Entsperr-Status** | Merkt, ob die PIN-Sperre entsperrt ist | Sitzungsspeicher (`chrome.storage.session`, wird beim Schließen des Browsers gelöscht) |
|
||||
| **Tresor-Einträge** (Titel, Benutzername, URL, Notizen, ob 2FA vorhanden) | Anzeige & Suche | flüchtig im Arbeitsspeicher (max. 5 Min zwischengespeichert) |
|
||||
| **Passwörter / 2FA-Codes** | Autofill & Kopieren | flüchtig, **nur im Moment der Nutzung** vom Server abgerufen; nicht dauerhaft gespeichert |
|
||||
| **Aktive Tab-Adresse** | Passende Vorschläge zur aufgerufenen Seite | flüchtig, nicht gespeichert, nicht übertragen (außer als Teil der API-Abfrage an deine Instanz) |
|
||||
|
||||
Die Erweiterung erstellt **keine** Nutzungsprofile und protokolliert **kein** Surfverhalten.
|
||||
|
||||
## 3. Datenübermittlung
|
||||
|
||||
- Es werden Daten **ausschließlich** an die von dir eingetragene **OpenNIT-Server-URL** übertragen
|
||||
(verschlüsselt via HTTPS, sofern deine Instanz HTTPS nutzt).
|
||||
- Übertragen werden: der API-Token (zur Authentifizierung), die aktuelle Seiten-Adresse (zum Finden
|
||||
passender Einträge), sowie beim Anlegen neuer Einträge die von dir eingegebenen Felder.
|
||||
- Es erfolgt **keine** Übermittlung an Anthropic, Google (außer der Chrome-Sync deines eigenen Browsers,
|
||||
falls du ihn aktiviert hast) oder sonstige Dritte.
|
||||
|
||||
## 4. Berechtigungen und warum sie nötig sind
|
||||
|
||||
Siehe [`docs/PERMISSIONS.md`](docs/PERMISSIONS.md). Kurz:
|
||||
|
||||
- **Zugriff auf alle Websites** (`host_permissions: <all_urls>`): nötig, um Login-Felder auf beliebigen
|
||||
Seiten zu erkennen und auf Wunsch auszufüllen. Es werden **keine** Seiteninhalte gelesen oder
|
||||
übertragen, außer den zum Ausfüllen nötigen Formularfeldern – und diese verlassen den Browser nicht.
|
||||
- **storage**: lokale Speicherung von URL, Token und Einstellungen.
|
||||
- **scripting / activeTab**: Einfügen der Zugangsdaten in das aktive Tab-Formular.
|
||||
- **alarms / offscreen**: automatisches Leeren der Zwischenablage nach dem Kopieren.
|
||||
|
||||
## 5. Zwischenablage
|
||||
|
||||
Kopierte Passwörter und 2FA-Codes werden – sofern aktiviert – nach etwa 30 Sekunden automatisch aus der
|
||||
Zwischenablage entfernt.
|
||||
|
||||
## 6. Speicherdauer
|
||||
|
||||
- Server-URL, Token und Einstellungen bleiben lokal gespeichert, bis du sie änderst oder die Erweiterung
|
||||
entfernst.
|
||||
- Zwischengespeicherte Einträge werden spätestens nach 5 Minuten bzw. beim Sperren verworfen.
|
||||
- Passwörter/2FA-Codes werden nicht dauerhaft gespeichert.
|
||||
|
||||
## 7. Deine Rechte
|
||||
|
||||
Da die eigentliche Datenverarbeitung in deiner OpenNIT-Instanz stattfindet, richten sich Auskunfts-,
|
||||
Lösch- und Berichtigungsrechte an deren Betreiber. Lokale Daten der Erweiterung entfernst du durch
|
||||
Deinstallieren der Erweiterung oder Löschen des Tokens in den Einstellungen.
|
||||
|
||||
## 8. Kontakt
|
||||
|
||||
Wende dich an den Betreiber deiner OpenNIT-Instanz. Für die Store-Veröffentlichung muss der Publisher hier
|
||||
eine erreichbare Kontaktadresse ergänzen.
|
||||
|
||||
---
|
||||
|
||||
# Privacy Policy – OpenNIT Vault (Browser Extension)
|
||||
|
||||
> Summary: The extension communicates **only** with the OpenNIT instance you configure. **No** telemetry,
|
||||
> **no** analytics/tracking, **no** third-party servers, **no** sale or sharing of data.
|
||||
|
||||
**Controller.** The operator of the OpenNIT instance you connect to. The extension itself has no server.
|
||||
|
||||
**Data processed.** Server URL, API token and settings (stored locally); unlock state (session storage,
|
||||
cleared when the browser closes); vault entry metadata (title, username, URL, notes, whether 2FA exists –
|
||||
cached in memory for up to 5 minutes); passwords/2FA codes (fetched **only** at the moment of use, never
|
||||
stored permanently); the active tab URL (to find matching entries).
|
||||
|
||||
**Transmission.** Data is sent **only** to your configured OpenNIT server URL over HTTPS. Nothing is sent
|
||||
to Anthropic, Google or any third party.
|
||||
|
||||
**Permissions.** Broad host access is required to detect and fill login fields on arbitrary sites; page
|
||||
content is not read or transmitted beyond the form fields needed for autofill, which never leave the
|
||||
browser. See [`docs/PERMISSIONS.md`](docs/PERMISSIONS.md).
|
||||
|
||||
**Clipboard.** Copied passwords/2FA codes are cleared automatically after ~30 seconds (if enabled).
|
||||
|
||||
**Retention.** Local settings persist until changed or the extension is removed; cached entries expire
|
||||
within 5 minutes; secrets are never stored persistently.
|
||||
|
||||
**Your rights.** Because processing happens in your OpenNIT instance, direct data-subject requests to its
|
||||
operator. Remove local extension data by uninstalling the extension or clearing the token.
|
||||
106
README.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<p align="center">
|
||||
<img src="extension/icon128.png" width="88" alt="OpenNIT Vault">
|
||||
</p>
|
||||
|
||||
<h1 align="center">OpenNIT Vault – Browser-Erweiterung</h1>
|
||||
|
||||
<p align="center">
|
||||
Passwort-Manager-Erweiterung für <a href="https://github.com/friloo/OpenNIT">OpenNIT</a> –
|
||||
Autofill für Benutzer-, Passwort- und 2FA-Felder direkt im Browser.
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Was ist das?
|
||||
|
||||
**OpenNIT Vault** ist die Browser-Erweiterung zum Passwort-Tresor von OpenNIT. Sie verbindet sich mit
|
||||
deiner selbst gehosteten OpenNIT-Instanz und bietet:
|
||||
|
||||
- 🔎 **Sofortsuche** über alle persönlichen und Team-Tresore
|
||||
- ⌨️ **Autofill** von Benutzername, Passwort und **2FA/TOTP-Codes** – auch auf mehrstufigen Login-Seiten
|
||||
- 💡 **Vorschläge direkt im Eingabefeld** (passend zur aufgerufenen Website)
|
||||
- 👁️ **Detailansicht** mit Anzeigen/Kopieren von Zugangsdaten und 2FA-Code mit Countdown
|
||||
- ➕ **Neue Einträge anlegen** inkl. **Passwort-Generator**
|
||||
- 🔒 **PIN-Sperre** mit demselben PIN wie der Web-Tresor (Dauer frei wählbar, bis „bis Browser schließt")
|
||||
- 🧹 **Zwischenablage-Auto-Clear** nach dem Kopieren von Geheimnissen
|
||||
- 🖼️ **Favicons** der hinterlegten Seiten (serverseitig gecacht – keine externen Aufrufe)
|
||||
- 🌙 **Heller & dunkler Modus** (folgt dem System)
|
||||
|
||||
<p align="center">
|
||||
<img src="store/screenshots/01-uebersicht.png" width="49%">
|
||||
<img src="store/screenshots/02-detail.png" width="49%">
|
||||
</p>
|
||||
|
||||
## Wie es funktioniert
|
||||
|
||||
Die Erweiterung ist ein reiner **Client** zu deiner OpenNIT-Instanz. Sie enthält **keinen** eigenen Server
|
||||
und **keinen** Remote-Code – alle Skripte sind im Paket enthalten. Kommuniziert wird ausschließlich mit dem
|
||||
von dir konfigurierten OpenNIT-Server über dessen REST-API (`/api/vault/extension/...`) per **Bearer-Token**.
|
||||
|
||||
- Der Token wird im OpenNIT-Web-Tresor erzeugt (Schaltfläche **„Extension"**) und in den
|
||||
Erweiterungs-Einstellungen hinterlegt.
|
||||
- Passwörter werden **serverseitig** ver-/entschlüsselt; die Erweiterung fordert das Klartext-Passwort
|
||||
eines Eintrags erst **im Moment des Ausfüllens/Kopierens** an – nicht beim Laden der Liste.
|
||||
- Es findet **keine** Ende-zu-Ende-Entschlüsselung im Browser statt; die Erweiterung speichert keine
|
||||
Passwörter dauerhaft (nur Server-URL, Token und Einstellungen in `chrome.storage`).
|
||||
|
||||
Details: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · Berechtigungen: [`docs/PERMISSIONS.md`](docs/PERMISSIONS.md)
|
||||
|
||||
> **SSO (neu ab 2.4.0):** „Mit OpenNIT anmelden" (OAuth 2.0 + PKCE) – Anmeldung wie an OpenNIT
|
||||
> (lokal + 2FA / M365 / Keycloak), automatische Token-Erneuerung mit Rotation. Der manuelle Token bleibt
|
||||
> als „Erweitert"-Option. Konzept/Details: [`docs/SSO-PLAN.md`](docs/SSO-PLAN.md).
|
||||
|
||||
## Installation
|
||||
|
||||
### Aus dem Quellcode (Entwickler / self-hosted)
|
||||
|
||||
1. `chrome://extensions` öffnen, **Entwicklermodus** aktivieren.
|
||||
2. **„Entpackte Erweiterung laden"** → den Ordner [`extension/`](extension/) auswählen.
|
||||
3. Erweiterung anheften, Einstellungen öffnen, **Server-URL** und **API-Token** eintragen.
|
||||
|
||||
Ausführliche Anleitung: [`docs/INSTALL.md`](docs/INSTALL.md).
|
||||
|
||||
### Aus dem OpenNIT-Backend
|
||||
|
||||
Jede OpenNIT-Instanz bietet unter **Passwort-Tresor → „Extension"** einen fertig konfigurierten
|
||||
ZIP-Download (mit vorausgefüllter Server-URL und Instanz-Icon). Diese generische Variante hier ist für die
|
||||
Veröffentlichung im Chrome Web Store bzw. als eigenständiges Repository gedacht.
|
||||
|
||||
### Chrome Web Store
|
||||
|
||||
Für die Store-Veröffentlichung `extension/` zu einem ZIP packen (siehe [`build.sh`](build.sh)) und im
|
||||
[Developer Dashboard](https://chrome.google.com/webstore/devconsole) hochladen. Checkliste und
|
||||
Store-Texte: [`store/`](store/).
|
||||
|
||||
## Konfiguration
|
||||
|
||||
| Einstellung | Beschreibung |
|
||||
|--------------------|--------------|
|
||||
| **Server-URL** | Adresse deiner OpenNIT-Instanz, ohne abschließenden Slash (`https://…`). |
|
||||
| **API-Token** | Im Web-Tresor unter „Extension" erzeugen und einfügen. |
|
||||
| **PIN-Sperre** | Aus / 5 Min / 15 Min / 1 Std / bis der Browser geschlossen wird. Nutzt den **Tresor-PIN**. |
|
||||
| **Zwischenablage** | Nach 30 s automatisch leeren (Standard: an). |
|
||||
|
||||
## Build / Paketierung
|
||||
|
||||
```bash
|
||||
./build.sh # erzeugt dist/opennit-vault-<version>.zip aus extension/
|
||||
```
|
||||
|
||||
## Sicherheit & Datenschutz
|
||||
|
||||
- Kein Remote-Code, keine Telemetrie, keine Drittanbieter-Server.
|
||||
- Datenfluss ausschließlich Browser ⇄ deine OpenNIT-Instanz.
|
||||
- Datenschutzerklärung: [`PRIVACY.md`](PRIVACY.md).
|
||||
|
||||
Sicherheitslücken bitte **nicht** über öffentliche Issues melden, sondern vertraulich an das OpenNIT-Team.
|
||||
|
||||
## Verhältnis zu OpenNIT
|
||||
|
||||
Diese Erweiterung ist Teil des OpenNIT-Projekts. Der Quellcode wird in OpenNIT serverseitig generiert
|
||||
(`src/Controllers/VaultApiController.php`); dieses Repository ist die eigenständige, generische Fassung
|
||||
für Distribution und Store.
|
||||
|
||||
## Lizenz
|
||||
|
||||
[GNU AGPL-3.0](LICENSE) – wie OpenNIT.
|
||||
20
build.sh
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
#!/usr/bin/env bash
|
||||
# Packt den Ordner extension/ in ein hochladbares ZIP (Chrome Web Store / entpackt).
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
VERSION="$(node -p "require('./extension/manifest.json').version" 2>/dev/null \
|
||||
|| grep -oE '"version"[^"]*"[^"]+"' extension/manifest.json | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')"
|
||||
|
||||
OUT="dist"
|
||||
ZIP="${OUT}/opennit-vault-${VERSION}.zip"
|
||||
|
||||
mkdir -p "$OUT"
|
||||
rm -f "$ZIP"
|
||||
|
||||
# Nur die Erweiterungsdateien zippen (keine Docs/Store-Assets).
|
||||
( cd extension && zip -r -X "../${ZIP}" . -x '.*' >/dev/null )
|
||||
|
||||
echo "Erstellt: ${ZIP}"
|
||||
echo "→ Im Chrome Web Store Developer Dashboard hochladen, oder in chrome://extensions entpackt laden."
|
||||
78
docs/ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Architektur
|
||||
|
||||
## Überblick
|
||||
|
||||
Die Erweiterung ist ein **Manifest-V3-Client** ohne eigenen Server. Sie besteht aus vier Kontexten, die
|
||||
über `chrome.runtime`-Nachrichten kommunizieren:
|
||||
|
||||
```
|
||||
┌───────────────┐ Messages ┌──────────────────────┐ HTTPS/Bearer ┌──────────────────┐
|
||||
│ popup.html │ ───────────▶ │ background.js │ ───────────────▶ │ OpenNIT-Server │
|
||||
│ popup.js │ ◀─────────── │ (Service Worker) │ ◀─────────────── │ /api/vault/... │
|
||||
└───────────────┘ │ - Cache (5 Min) │ └──────────────────┘
|
||||
┌───────────────┐ Messages │ - Lock-Gate │
|
||||
│ content.js │ ───────────▶ │ - Favicon-Cache │
|
||||
│ (jede Seite) │ ◀─────────── │ - Clipboard-Timer │
|
||||
└───────────────┘ └──────────┬───────────┘
|
||||
│ CLIP_WRITE
|
||||
┌────────▼─────────┐
|
||||
│ offscreen.html │ (Zwischenablage leeren)
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## Komponenten
|
||||
|
||||
| Datei | Rolle |
|
||||
|-------|-------|
|
||||
| `background.js` | Zentrale Logik: API-Aufrufe, 5-Minuten-Cache, **Lock-Gate**, Favicon-Cache (Data-URLs), Zwischenablage-Timer. Alle Secrets fließen hier durch. |
|
||||
| `content.js` | Wird auf jeder Seite ausgeführt. Erkennt Benutzer-/Passwort-/OTP-Felder (inkl. Shadow-DOM, mehrstufige Logins, segmentierte OTP-Felder), zeigt das Vorschlags-Dropdown und füllt Felder framework-kompatibel (React/Vue/Angular). |
|
||||
| `popup.html` / `popup.js` | Toolbar-Popup: Liste, Suche, Detailansicht, Anlegen + Generator, PIN-Schirm. |
|
||||
| `options.html` / `options.js` | Einstellungen: Server-URL, Token, PIN-Sperrdauer, Zwischenablage. |
|
||||
| `offscreen.html` / `offscreen.js` | Minimaldokument, das ausschließlich die Zwischenablage leert (MV3-konform). |
|
||||
|
||||
## Nachrichten (Auszug)
|
||||
|
||||
| Typ | Von → Nach | Zweck |
|
||||
|-----|-----------|-------|
|
||||
| `CHECK_STATUS` | popup/content → bg | Token prüfen, App-Name/User, `pin_enabled` |
|
||||
| `GET_LOCK` / `DO_UNLOCK` / `LOCK_NOW` | popup → bg | PIN-Sperre abfragen/entsperren/sperren |
|
||||
| `GET_ENTRIES` / `GET_MATCHING_ENTRIES` | popup/content → bg | Einträge (alle / passend zur URL) |
|
||||
| `GET_PASSWORD` / `GET_TOTP` | popup/content → bg | Secret **on demand** |
|
||||
| `GET_FAVICON` | popup/content → bg | Favicon als Data-URL (serverseitig gecacht) |
|
||||
| `VAULT_FILL` | popup → content | Aktives Tab-Formular ausfüllen |
|
||||
| `SCHEDULE_CLIP_CLEAR` | popup/content → bg | Zwischenablage-Leerung planen |
|
||||
|
||||
## Server-API (in OpenNIT)
|
||||
|
||||
Alle Endpunkte unter `/api/vault/extension/` mit `Authorization: Bearer <token>`:
|
||||
|
||||
- `GET /entries` – Liste (Titel, Benutzer, URL, Notizen, `has_totp`, `favicon_domain`, `has_favicon`)
|
||||
- `GET /entries/{id}/password` – Passwort (protokolliert im Audit-Log)
|
||||
- `GET /entries/{id}/totp` – aktueller TOTP-Code + Restsekunden
|
||||
- `GET /entries/{id}/favicon?fetch=1` – gecachtes Favicon (bei Bedarf serverseitig geholt)
|
||||
- `POST /entries` – neuen Eintrag anlegen
|
||||
- `GET /status` – Token gültig? + `pin_enabled` / `pin_lock_secs`
|
||||
- `POST /unlock` – Tresor-PIN verifizieren + serverseitiges Entsperr-Fenster für den Token setzen
|
||||
- `POST /lock` – Token sofort wieder sperren (Entsperr-Fenster zurücksetzen)
|
||||
- `GET /oauth/authorize` · `POST /oauth/authorize` – SSO-Anmeldung/Zustimmung (Session, PKCE)
|
||||
- `POST /oauth/token` – Authorization-Code- bzw. Refresh-Grant (öffentlich, PKCE) → Access/Refresh
|
||||
- `POST /oauth/revoke` – Refresh-Kette widerrufen (Logout)
|
||||
|
||||
Details zum SSO-Flow: [`SSO-PLAN.md`](SSO-PLAN.md). Die Erweiterung nutzt SSO über `chrome.identity`;
|
||||
Access-Tokens werden im Hintergrund still per Refresh (mit Rotation) erneuert.
|
||||
|
||||
## Lock-Gate (serverseitig erzwungen)
|
||||
|
||||
Ist für den Nutzer ein **Tresor-PIN** aktiv, liefern die Server-Endpunkte für Einträge/Passwort/TOTP
|
||||
erst nach frischer PIN-Entsperrung Daten (`unlocked_until` pro Token) und antworten sonst mit **HTTP 423**.
|
||||
Der Client spiegelt den Zustand nur (PIN-Schirm) – die eigentliche Durchsetzung liegt im Server, damit ein
|
||||
**gestohlener Token allein wertlos** ist. Der Client hält seinen Entsperr-Status zusätzlich in
|
||||
`chrome.storage.session` (verfällt beim Schließen des Browsers). Die Einstellung „PIN-Sperre" legt nur die
|
||||
Fensterdauer fest; „Bis der Browser geschlossen wird" nutzt ein langes Serverfenster + Client-Sitzungsende.
|
||||
|
||||
## Sicherheitsprinzipien
|
||||
|
||||
- **Kein Remote-Code** – alle Skripte im Paket (MV3-CSP-konform, keine Inline-Skripte).
|
||||
- **Secrets on demand** – Passwörter/TOTP erst bei Nutzung, nie in der Liste.
|
||||
- **Kein persistentes Secret** – nur URL, Token, Einstellungen in `chrome.storage`.
|
||||
- **Server-seitige Krypto** – Ver-/Entschlüsselung im OpenNIT-Server, nicht im Browser.
|
||||
76
docs/INSTALL.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Installation & Einrichtung
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Eine erreichbare **OpenNIT-Instanz** mit aktiviertem Modul **Passwort-Tresor**.
|
||||
- Ein Chromium-basierter Browser (Chrome, Edge, Brave, Vivaldi …).
|
||||
|
||||
## 1. Erweiterung laden
|
||||
|
||||
### A) Entpackt aus dem Quellcode (Entwicklung / self-hosted)
|
||||
|
||||
1. `chrome://extensions` öffnen.
|
||||
2. Oben rechts **Entwicklermodus** einschalten.
|
||||
3. **„Entpackte Erweiterung laden"** klicken und den Ordner **`extension/`** auswählen.
|
||||
4. Die Erweiterung erscheint in der Liste; per Puzzle-Symbol an die Toolbar anheften.
|
||||
|
||||
### B) Aus dem Chrome Web Store
|
||||
|
||||
Sobald veröffentlicht: im Store nach **„OpenNIT Vault"** suchen und **„Hinzufügen"** klicken.
|
||||
|
||||
### C) Fertiges ZIP aus dem OpenNIT-Backend
|
||||
|
||||
Im OpenNIT-Web-Tresor gibt es unter **„Extension"** einen ZIP-Download mit bereits vorausgefüllter
|
||||
Server-URL. Diesen entpacken und wie unter **A)** laden.
|
||||
|
||||
## 2. Anmelden
|
||||
|
||||
**Empfohlen – SSO:** In den Erweiterungs-Einstellungen die **Server-URL** eintragen und auf
|
||||
**„Mit OpenNIT anmelden"** klicken. Es öffnet sich die gewohnte OpenNIT-Anmeldung (lokal + 2FA /
|
||||
Microsoft 365 / Keycloak). Nach erfolgreicher Anmeldung und Zustimmung ist die Erweiterung verbunden –
|
||||
der Zugang wird automatisch erneuert. Über **„Abmelden"** wird die Sitzung serverseitig widerrufen.
|
||||
|
||||
> Voraussetzung: Der Betreiber muss ggf. die Redirect-URI der Erweiterung hinterlegen
|
||||
> (Backend → Administration → **Vault-Erweiterung**). `*.chromiumapp.org` ist standardmäßig erlaubt.
|
||||
|
||||
**Alternative – manueller Token** (Kiosk/Headless ohne interaktiven Login): siehe Abschnitt „Erweitert"
|
||||
in den Einstellungen und die folgenden Schritte.
|
||||
|
||||
## 2b. Manuellen Token erzeugen
|
||||
|
||||
1. In OpenNIT den **Passwort-Tresor** öffnen.
|
||||
2. Auf **„Extension"** klicken und einen **API-Token** generieren.
|
||||
3. Der Token wird **nur einmal** angezeigt – kopieren.
|
||||
|
||||
> Der Token verschlüsselt serverseitig deinen Vault-Schlüssel. Behandle ihn wie ein Passwort.
|
||||
|
||||
## 3. Erweiterung konfigurieren
|
||||
|
||||
1. Auf das OpenNIT-Vault-Symbol klicken → Zahnrad **Einstellungen** (oder `chrome://extensions` →
|
||||
Details → Erweiterungsoptionen).
|
||||
2. **Server-URL** eintragen (z. B. `https://vault.firma.de`, ohne `/` am Ende).
|
||||
3. **API-Token** einfügen → **Speichern**.
|
||||
4. **Verbindung testen** – es sollte „Verbunden als …" erscheinen.
|
||||
|
||||
## 4. Optional: PIN-Sperre
|
||||
|
||||
1. In OpenNIT unter **Tresor-PIN** einen PIN festlegen (falls noch nicht geschehen).
|
||||
2. In den Erweiterungs-Einstellungen unter **Sicherheit** eine **Sperrdauer** wählen
|
||||
(5 Min / 15 Min / 1 Std / bis der Browser geschlossen wird).
|
||||
3. Ab jetzt verlangt die Erweiterung nach Ablauf den **Tresor-PIN**, bevor Zugangsdaten sichtbar werden.
|
||||
|
||||
## 5. Nutzung
|
||||
|
||||
- **Autofill:** Login-Feld auf einer Website anklicken → Vorschläge erscheinen → Eintrag wählen.
|
||||
- **Popup:** Symbol anklicken → suchen → Eintrag anklicken für Details (Anzeigen/Kopieren, 2FA,
|
||||
„Auf dieser Seite ausfüllen").
|
||||
- **Neu anlegen:** im Popup auf **+** → Felder ausfüllen, Passwort per Generator erzeugen → Speichern.
|
||||
|
||||
## Fehlerbehebung
|
||||
|
||||
| Problem | Ursache / Lösung |
|
||||
|--------|------------------|
|
||||
| „Nicht verbunden" | Server-URL/Token prüfen; endet die URL ohne `/`? Ist die Instanz erreichbar (HTTPS/Zertifikat)? |
|
||||
| Keine Vorschläge auf einer Seite | Ist der Tresor per PIN gesperrt? Passt eine hinterlegte URL zur Domain? Seite neu laden. |
|
||||
| „Seite nicht bereit" beim Ausfüllen | Seite einmal neu laden, damit das Content-Script aktiv ist. |
|
||||
| Favicons fehlen | Werden serverseitig per Cron nachgeladen; erscheinen nach dem ersten Durchlauf. |
|
||||
59
docs/PERMISSIONS.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Berechtigungen – Begründung
|
||||
|
||||
Diese Übersicht erklärt jede angeforderte Berechtigung (auch für die Chrome-Web-Store-Prüfung).
|
||||
|
||||
## `host_permissions: ["<all_urls>"]`
|
||||
|
||||
**Warum:** Ein Passwort-Manager muss Login-Felder auf **beliebigen** Websites erkennen und auf Wunsch
|
||||
ausfüllen können. Deshalb ist Zugriff auf alle URLs erforderlich.
|
||||
|
||||
**Was NICHT passiert:** Es werden keine Seiteninhalte gelesen, gespeichert oder übertragen – ausschließlich
|
||||
Formularfelder (Benutzer/Passwort/OTP) werden erkannt und beim Ausfüllen beschrieben. Diese verlassen den
|
||||
Browser nicht. Es findet kein Tracking und keine Analyse statt.
|
||||
|
||||
## `content_scripts` (matches `<all_urls>`, `run_at: document_idle`)
|
||||
|
||||
**Warum:** Das In-Seite-Dropdown mit Vorschlägen und die Felderkennung laufen als Content-Script.
|
||||
Notwendig für Autofill und die 2FA-Erkennung (inkl. Shadow-DOM und mehrstufiger Logins).
|
||||
|
||||
## `scripting`
|
||||
|
||||
**Warum:** Werte werden über den nativen Value-Setter gesetzt und Events ausgelöst, damit auch
|
||||
React/Vue/Angular-Formulare die Eingaben übernehmen.
|
||||
|
||||
## `activeTab`
|
||||
|
||||
**Warum:** Zugriff auf den aktiven Tab beim Ausfüllen aus dem Popup („Auf dieser Seite ausfüllen").
|
||||
|
||||
## `storage`
|
||||
|
||||
**Warum:** Lokale Speicherung von Server-URL, API-Token und Einstellungen; Entsperr-Status in
|
||||
`storage.session`.
|
||||
|
||||
## `alarms`
|
||||
|
||||
**Warum:** Zeitgesteuertes Leeren der Zwischenablage (~30 s) sowie periodisches Verwerfen des
|
||||
Einträge-Caches (5 Min).
|
||||
|
||||
## `offscreen`
|
||||
|
||||
**Warum:** In Manifest V3 hat der Service Worker keinen DOM-Zugriff. Zum programmatischen Leeren der
|
||||
Zwischenablage wird ein kurzlebiges Offscreen-Dokument (Reason `CLIPBOARD`) genutzt.
|
||||
|
||||
## `identity`
|
||||
|
||||
**Warum:** Für die **SSO-Anmeldung** (`chrome.identity.launchWebAuthFlow`, OAuth 2.0 + PKCE). Öffnet die
|
||||
OpenNIT-Login-Seite und empfängt die Weiterleitung an `https://<extension-id>.chromiumapp.org/`. Es wird
|
||||
**kein** Zugriff auf Google-Konten o. Ä. genommen – nur der Web-Auth-Flow zur konfigurierten OpenNIT-Instanz.
|
||||
|
||||
## Bewusst NICHT angefordert
|
||||
|
||||
- **`tabs`** – entfällt: Die aktive Tab-Adresse ist bereits über `host_permissions` verfügbar. Dadurch
|
||||
erscheint **keine** „Browserverlauf lesen"-Warnung.
|
||||
- Keine `cookies`, `history`, `webRequest`, `downloads`, `notifications` o. Ä.
|
||||
|
||||
## Single-Purpose-Erklärung (für den Store)
|
||||
|
||||
> OpenNIT Vault dient einem einzigen Zweck: dem Verwalten und automatischen Ausfüllen von Zugangsdaten
|
||||
> und 2FA-Codes aus einer selbst gehosteten OpenNIT-Instanz. Alle Berechtigungen dienen ausschließlich
|
||||
> diesem Zweck.
|
||||
276
docs/SSO-PLAN.md
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
# Umsetzungsplan: SSO-Anmeldung für die OpenNIT-Vault-Erweiterung
|
||||
|
||||
_Status: Konzept (noch nicht implementiert) · Stand 2026-07-01_
|
||||
|
||||
Ziel: Der Nutzer installiert die Erweiterung, gibt **nur die Server-URL** ein und **meldet sich an wie an
|
||||
OpenNIT** (lokaler Login + 2. Faktor / Microsoft 365 / Keycloak). Die Erweiterung erhält daraufhin ihre
|
||||
Zugangstokens **automatisch** – kein manuelles Kopieren mehr. Kurz vor Ablauf fordert die Erweiterung eine
|
||||
erneute Anmeldung (Re-Auth).
|
||||
|
||||
Dieses Dokument ist der **gründliche Umsetzungsplan** – kein Code.
|
||||
|
||||
---
|
||||
|
||||
## 1. Leitidee
|
||||
|
||||
**OpenNIT wird zum OAuth-2.0-Autorisierungsserver für seine eigene Erweiterung.**
|
||||
|
||||
Die Erweiterung ist ein **öffentlicher OAuth-Client** (kein Client-Secret) und spricht **ausschließlich mit
|
||||
OpenNIT** – nicht direkt mit Microsoft/Keycloak. Wie sich der Nutzer bei OpenNIT anmeldet (lokal + TOTP/
|
||||
WebAuthn, Azure AD, Keycloak), ist für die Erweiterung **transparent**: Sie nutzt einfach die bestehende
|
||||
OpenNIT-Login-Seite. Das löst automatisch **alle** Anmeldemethoden mit einem einzigen Extension-Flow.
|
||||
|
||||
Verwendeter Standard: **OAuth 2.0 Authorization Code Flow mit PKCE** (RFC 7636), umgesetzt über die
|
||||
Browser-API **`chrome.identity.launchWebAuthFlow`**.
|
||||
|
||||
> Bereits vorhandene Bausteine in OpenNIT, auf denen aufgesetzt wird:
|
||||
> `src/Auth/OAuthClient.php` (OIDC/PKCE zu Azure/Keycloak), `src/Auth/TokenManager.php`,
|
||||
> `src/Auth/SessionManager.php`, `src/Controllers/AuthController.php` (lokaler + Azure + Keycloak Login),
|
||||
> `src/Vault/VaultManager.php` (`unlockOrSetup`, `unlockOrSetupSso`, `storeVmkInSession`,
|
||||
> `getVmkFromSession`, PIN-Entsperrung), sowie das bestehende Token-Modell in
|
||||
> `src/Controllers/VaultApiController.php` (`tokenGenerate`, VMK-Wrapping via `deriveKeyFromToken`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Zwei Token-Typen (statt einem 180-Tage-Token)
|
||||
|
||||
| Token | Lebensdauer (Vorschlag) | Speicherort | Zweck |
|
||||
|-------|-------------------------|-------------|-------|
|
||||
| **Access-Token** | kurz (15–60 Min) | `chrome.storage.session` (flüchtig) | Bearer für alle `/api/vault/extension/*`-Aufrufe |
|
||||
| **Refresh-Token** | lang (14–30 Tage, rotierend) | `chrome.storage.local` | Holt still neue Access-Tokens; wird bei jeder Nutzung **rotiert** |
|
||||
|
||||
Vorteile gegenüber dem heutigen manuellen 180-Tage-Token:
|
||||
- Ein gestohlener **Access-Token** verfällt in Minuten.
|
||||
- Der **Refresh-Token rotiert** bei jeder Nutzung → **Diebstahl-Erkennung** (Reuse Detection).
|
||||
- **Kein Copy-&-Paste** eines langlebigen Geheimnisses.
|
||||
- **Zentraler Widerruf** über die Web-Oberfläche (Sitzungen beenden).
|
||||
- Kombinierbar mit dem bereits umgesetzten **serverseitigen PIN-Gate** (Defense in Depth).
|
||||
|
||||
---
|
||||
|
||||
## 3. Der VMK-Kern (wichtigster Design-Punkt)
|
||||
|
||||
OpenNIT ver-/entschlüsselt Tresor-Einträge serverseitig mit dem **Vault Master Key (VMK)**. Jeder
|
||||
Extension-Token muss den VMK also (server-seitig) verfügbar machen. Heute wrappt `tokenGenerate` den VMK
|
||||
unter einem aus dem Token abgeleiteten Schlüssel.
|
||||
|
||||
**Herausforderung:** Beim **stillen Refresh** (ohne Nutzerinteraktion, ohne Passwort) muss der Server den
|
||||
VMK weiterhin bereitstellen können.
|
||||
|
||||
**Lösung – VMK „wandert" mit dem Refresh-Token:**
|
||||
|
||||
1. **Bei der Erstanmeldung** (Authorization-Code-Grant) ist der Web-Login gerade erfolgt → der VMK liegt in
|
||||
der Session (lokaler Login entsperrt per Passwort; SSO-Nutzer per `unlockOrSetupSso`). Der Server:
|
||||
- wrappt den VMK unter einem aus dem **Refresh-Token** abgeleiteten Schlüssel → speichert ihn in der
|
||||
Refresh-Zeile,
|
||||
- wrappt den VMK unter einem aus dem **Access-Token** abgeleiteten Schlüssel → Access-Zeile (kurzlebig).
|
||||
2. **Beim Refresh:** Server entpackt den VMK mit dem Refresh-Schlüssel, **rotiert** (neuer Refresh-Token,
|
||||
VMK neu gewrappt, alter Token als „rotiert" markiert), gibt einen neuen Access-Token (VMK gewrappt) aus.
|
||||
|
||||
Damit ist der **Refresh-Token** faktisch das langlebige VMK-tragende Geheimnis (wie heute der manuelle
|
||||
Token) – aber **kürzerlebig, rotierend, per SSO bezogen und widerrufbar**. Der **Access-Token** ist das
|
||||
kurzlebige Arbeitspferd.
|
||||
|
||||
> Hinweis PIN-Gate: Das serverseitige PIN-Gate (bereits umgesetzt) bleibt orthogonal bestehen – auch mit
|
||||
> gültigem Access-Token liefern die Secret-Endpunkte bei aktivem Tresor-PIN erst nach PIN-Entsperrung.
|
||||
> Ob das PIN-Gate bei SSO zusätzlich gefordert wird, ist eine Betreiber-Entscheidung (siehe §9).
|
||||
|
||||
---
|
||||
|
||||
## 4. Ablauf (Sequenz)
|
||||
|
||||
```
|
||||
Erweiterung OpenNIT (AS) IdP (M365/Keycloak/lokal)
|
||||
│ │ │
|
||||
│ 1. launchWebAuthFlow(authorize? │ │
|
||||
│ client_id, redirect_uri, │ │
|
||||
│ code_challenge, state, scope) │ │
|
||||
│──────────────────────────────────▶│ │
|
||||
│ │ 2. Keine Session? → /auth/login │
|
||||
│ │────────── Login (lokal+2FA/SSO) ───────▶│
|
||||
│ │◀───────────── authentifiziert ─────────│
|
||||
│ │ 3. Vault entsperrt? sonst PIN-Prompt │
|
||||
│ │ 4. Zustimmung („Vault-Zugriff erlauben")│
|
||||
│◀── 5. Redirect: redirect_uri?code=…&state ─┤ │
|
||||
│ 6. state prüfen, code extrahieren │ │
|
||||
│ 7. POST /oauth/token │ │
|
||||
│ (code, code_verifier) │ │
|
||||
│──────────────────────────────────▶│ 8. PKCE prüfen, VMK wrappen │
|
||||
│◀── {access_token, refresh_token, expires_in, refresh_expires_in} ──────────┤
|
||||
│ 9. Tokens speichern, loslegen │ │
|
||||
│ … │ │
|
||||
│ 10. Access-Token abgelaufen → POST /oauth/token (grant=refresh_token) │
|
||||
│──────────────────────────────────▶│ 11. rotieren, neuen Access ausgeben │
|
||||
│◀───────────────────────────────────┤ │
|
||||
```
|
||||
|
||||
Silent-Refresh (Schritt 10/11) läuft unsichtbar. Erst wenn der **Refresh-Token abläuft** oder der Refresh
|
||||
scheitert, startet die Erweiterung erneut `launchWebAuthFlow` – bei noch lebender OpenNIT/IdP-Sitzung ist
|
||||
das ein **stiller Redirect ohne Eingabe**, sonst ein voller Login.
|
||||
|
||||
---
|
||||
|
||||
## 5. Neue Server-Endpunkte
|
||||
|
||||
Alle unter `/api/vault/extension/oauth/`:
|
||||
|
||||
| Methode & Pfad | Auth | Zweck |
|
||||
|----------------|------|-------|
|
||||
| `GET /authorize` | Web-Session (Login-Redirect) | Zeigt Zustimmung, ggf. PIN-Entsperrung; erzeugt Auth-Code |
|
||||
| `POST /token` | öffentlich (PKCE) | `grant_type=authorization_code` **oder** `refresh_token` → Access/Refresh |
|
||||
| `POST /revoke` | Bearer/Refresh | Widerruft einen Refresh-Token (Logout in der Erweiterung) |
|
||||
| `GET /sessions` (Web) | Web-Session | Liste aktiver Erweiterungs-Sitzungen im Web-Vault |
|
||||
| `POST /sessions/{id}/revoke` (Web) | Web-Session | Einzelne Sitzung serverseitig beenden |
|
||||
|
||||
Bestehende Endpunkte (`/entries`, `/entries/{id}/password`, `/totp`, `/favicon`, `/status`, `/unlock`,
|
||||
`/lock`) bleiben unverändert – sie akzeptieren künftig **Access-Tokens** genauso wie die bisherigen
|
||||
manuellen Tokens (siehe §8 Kompatibilität).
|
||||
|
||||
---
|
||||
|
||||
## 6. Datenmodell (neue Migrationen)
|
||||
|
||||
**`vault_oauth_auth_codes`** (kurzlebige Autorisierungscodes)
|
||||
```
|
||||
code_hash CHAR(64) PK -- sha256(code)
|
||||
user_id BIGINT
|
||||
code_challenge VARCHAR(128) -- PKCE (S256)
|
||||
redirect_uri VARCHAR(255)
|
||||
scope VARCHAR(255)
|
||||
expires_at DATETIME -- ~60 Sekunden
|
||||
used_at DATETIME NULL -- Einmalverwendung
|
||||
created_at DATETIME
|
||||
```
|
||||
|
||||
**`vault_oauth_refresh_tokens`** (langlebig, VMK-tragend, rotierend)
|
||||
```
|
||||
id BIGINT PK
|
||||
user_id BIGINT
|
||||
token_hash CHAR(64) -- sha256(refresh_token)
|
||||
vmk_enc/nonce/tag -- VMK gewrappt unter Refresh-Schlüssel (HKDF)
|
||||
device_label VARCHAR(120) -- z. B. "Chrome auf Laptop"
|
||||
rotated_from BIGINT NULL -- Vorgänger (Reuse-Detection-Kette)
|
||||
revoked TINYINT DEFAULT 0
|
||||
refresh_expires_at DATETIME -- absolutes Ablaufdatum (14–30 Tage)
|
||||
created_at, last_used_at DATETIME
|
||||
```
|
||||
|
||||
**Access-Tokens:** die vorhandene Tabelle `vault_extension_tokens` weiternutzen (kurzes `expires_at`,
|
||||
`unlocked_until` für das PIN-Gate). Optional Spalte `refresh_id` (Herkunft) für Bulk-Revoke.
|
||||
|
||||
**Client-Registrierung:** ein fester `client_id` für die Erweiterung + erlaubte Redirect-URIs, konfiguriert
|
||||
in `system_settings` bzw. einer kleinen `vault_oauth_clients`-Tabelle (Admin-GUI, siehe §7).
|
||||
|
||||
---
|
||||
|
||||
## 7. Admin-Konfiguration (Pflicht laut OpenNIT-Konventionen)
|
||||
|
||||
Neue Admin-Seite `/admin/vault/extension` (Layout `layouts/admin`, Capability `manage_vault_*`), nur bei
|
||||
aktivem Vault-Modul:
|
||||
- **Extension-Client-ID** (Vorgabe fix) und **erlaubte Redirect-URIs**. Empfehlung: die konkrete
|
||||
`https://<extension-id>.chromiumapp.org/` **pinnen** (Store-ID bzw. Unpacked-ID), statt Wildcard.
|
||||
- **Token-Lebensdauern**: Access (15–60 Min), Refresh (14–30 Tage), Re-Auth-Vorwarnung (Tage).
|
||||
- **SSO-Login in der Erweiterung**: an/aus; welche Methoden angeboten werden (erbt aus dem Web-Login).
|
||||
- **PIN-Gate bei SSO**: zusätzlich fordern (Defense in Depth) oder bei erfolgreichem SSO überspringen.
|
||||
- Übersicht/Widerruf aktiver Sitzungen.
|
||||
|
||||
Alle Werte in der DB (`system_settings`), nicht in Config-Dateien (OpenNIT-Regel). Secrets nie ins Audit-Log.
|
||||
|
||||
---
|
||||
|
||||
## 8. Erweiterungs-Seite (Client)
|
||||
|
||||
- Manifest: Berechtigung **`identity`** ergänzen; Redirect-URI ist `chrome.identity.getRedirectURL()`
|
||||
(`https://<id>.chromiumapp.org/`).
|
||||
- **Options/Popup:** Button **„Mit OpenNIT anmelden"** (nach Eingabe der Server-URL). Startet
|
||||
`launchWebAuthFlow({interactive:true})`.
|
||||
- **Token-Haltung:** Refresh-Token in `chrome.storage.local`, Access-Token + Ablauf in
|
||||
`chrome.storage.session`.
|
||||
- **Auto-Refresh:** Der Background-Service-Worker hält den Access-Token frisch; vor jedem API-Call bei
|
||||
Ablauf still refreshen. Bei `401`/abgelaufenem Refresh → interaktiver Re-Login.
|
||||
- **Re-Auth-Vorwarnung:** X Tage vor `refresh_expires_at` ein dezenter Hinweis „Bitte neu anmelden".
|
||||
- **PKCE/State** clientseitig erzeugen (Web Crypto). `state` gegen CSRF prüfen.
|
||||
- **Logout:** `POST /oauth/revoke` + lokale Tokens löschen.
|
||||
|
||||
---
|
||||
|
||||
## 9. Sicherheitsdesign & Bedrohungsmodell
|
||||
|
||||
**Was SSO verbessert (ggü. manuellem Token):**
|
||||
- Kein langlebiges Klartext-Geheimnis zum Kopieren.
|
||||
- Access-Tokens kurzlebig; Refresh-Tokens **rotieren** → Reuse-Detection: Wird ein bereits rotierter
|
||||
Refresh-Token erneut vorgelegt, wird die **gesamte Kette widerrufen** und Re-Login erzwungen (+ Audit-Alarm).
|
||||
- Wiederverwendung der **vorhandenen Anmeldung inkl. 2. Faktor** (M365/Keycloak/lokal+TOTP/WebAuthn).
|
||||
- **Zentraler Widerruf** je Gerät/Sitzung.
|
||||
|
||||
**Pflicht-Härtungen im Flow:**
|
||||
- **PKCE (S256)** – öffentlicher Client, kein Secret.
|
||||
- **Redirect-URI-Allowlist** (Extension-ID pinnen).
|
||||
- **`state`** gegen CSRF; **Auth-Code** einmalig, ~60 s gültig, an PKCE-Challenge + redirect_uri + user gebunden.
|
||||
- **Rate-Limiting** auf `/authorize`, `/token` (bestehender RateLimiter greift; zusätzlich pro Nutzer/Client).
|
||||
- **Audit-Log** für Ausgabe/Refresh/Rotation/Reuse/Revoke.
|
||||
- **Nur HTTPS** (bereits in der Erweiterung erzwungen).
|
||||
|
||||
**Was bestehen bleibt (bewusst):**
|
||||
- Der **Refresh-Token ist VMK-tragend „at rest"** in `chrome.storage.local` – wie heute der Token.
|
||||
Restrisiko gemindert durch: Rotation, kürzere Lebensdauer, **PIN-Gate**, optionales **Geräte-Binding**
|
||||
(client-generierte Device-ID als zusätzlicher Faktor beim Refresh), und schnellen Widerruf.
|
||||
- `chrome.storage` ist nicht hardware-gebunden – ein vollständig kompromittiertes Endgerät bleibt ein
|
||||
vollständig kompromittiertes Endgerät (gilt für jeden Passwort-Manager).
|
||||
- Der Server kann prinzipbedingt den VMK entpacken (nötig für serverseitige Krypto). DB-Zugriff = Vollzugriff
|
||||
(unverändert; separat durch DB-/Server-Härtung zu adressieren).
|
||||
|
||||
---
|
||||
|
||||
## 10. Kompatibilität & Migration
|
||||
|
||||
- **Manuelle Tokens bleiben gültig** (Tabelle `vault_extension_tokens`) – kein Bruch bestehender
|
||||
Installationen. Der SSO-Flow ist **additiv**.
|
||||
- Die Secret-Endpunkte akzeptieren Access-Tokens **und** Alt-Tokens (dieselbe `authenticateByToken`-Logik,
|
||||
ergänzt um Access-Token-Lookup).
|
||||
- Options-Seite: **„Mit OpenNIT anmelden"** wird der Standardweg; **manueller Token** wandert unter
|
||||
„Erweitert" (für Umgebungen ohne interaktiven Login, z. B. Kiosk/Headless).
|
||||
- Empfehlung: nach Einführung die Standard-Laufzeit manueller Tokens verkürzen.
|
||||
|
||||
---
|
||||
|
||||
## 11. Phasenplan & Aufwand (grob)
|
||||
|
||||
| Phase | Inhalt | Aufwand |
|
||||
|-------|--------|---------|
|
||||
| **0. Feinkonzept** | Endpunkt-/DB-Spezifikation, Admin-Settings festzurren, Lebensdauern | 0,5–1 Tag |
|
||||
| **1. Server-AS** | `authorize`/`token`/`revoke`, Migrationen, VMK-Wrapping+Rotation, Reuse-Detection, Audit | 3–5 Tage |
|
||||
| **2. Admin-GUI** | Client-/Redirect-Config, Lebensdauern, Sitzungsübersicht/Widerruf | 1–2 Tage |
|
||||
| **3. Erweiterung** | `identity`-Flow, Token-Haltung, Auto-Refresh, Re-Auth-UX, Logout | 2–3 Tage |
|
||||
| **4. Härtung & Test** | Rate-Limits, Edge-Cases (PIN-gesperrter Vault beim authorize, SSO-only-Nutzer), End-to-End-Tests | 1–2 Tage |
|
||||
| **5. Doku & Rollout** | Handbuch, Store-Update, Deprecation-Hinweis manueller Token | 0,5–1 Tag |
|
||||
|
||||
**Gesamt: ~1,5–2,5 Wochen** für eine solide erste Version. Da OAuth/PKCE-Infrastruktur (`OAuthClient`,
|
||||
`TokenManager`) und der Vault-Unlock (`unlockOrSetupSso`) bereits existieren, ist ein Teil der Grundlage da.
|
||||
|
||||
---
|
||||
|
||||
## 12. Edge-Cases, die das Feinkonzept klären muss
|
||||
|
||||
- **PIN-gesperrter Vault beim `authorize`**: Ist der Web-Vault gerade PIN-gesperrt, liegt der VMK nicht in
|
||||
der Session → im Consent-Schritt PIN-Entsperrung verlangen (vorhandener PIN-Flow).
|
||||
- **SSO-only-Nutzer** (kein lokales Passwort): VMK-Bereitstellung über `unlockOrSetupSso` – am `authorize`
|
||||
bereits gegeben, da der Login gerade lief.
|
||||
- **Team-Schlüssel**: `provisionPendingTeamKeys` beim Token-Issuing berücksichtigen (wie heute).
|
||||
- **Mehrere Geräte**: pro Gerät eine Refresh-Kette (`device_label`), unabhängig widerrufbar.
|
||||
- **Passwortänderung / VMK-Rotation** serverseitig: bestehende Refresh-Tokens ggf. invalidieren → Re-Login.
|
||||
- **Uhrzeit/Ablauf**: absolute Ablaufzeiten serverseitig führend.
|
||||
|
||||
---
|
||||
|
||||
## 13. Offene Entscheidungen (für dich)
|
||||
|
||||
1. **Lebensdauern**: Access-Token (15/30/60 Min?) und Refresh-Token (14/30 Tage?) + Re-Auth-Vorwarnung (Tage?).
|
||||
2. **Manuellen Token behalten** (als „Erweitert"-Fallback) oder mittelfristig entfernen?
|
||||
3. **PIN-Gate bei SSO**: zusätzlich fordern (max. Sicherheit) oder nach erfolgreichem SSO überspringen (Komfort)?
|
||||
4. **Redirect-URI**: Extension-ID pinnen (empfohlen) vs. `*.chromiumapp.org` erlauben?
|
||||
5. **Geräte-Binding** des Refresh-Tokens umsetzen (empfohlen) – ja/nein?
|
||||
6. **Angebotene Login-Methoden** in der Erweiterung: alle aus dem Web-Login (lokal+2FA / Azure / Keycloak)?
|
||||
|
||||
> Sobald diese sechs Punkte entschieden sind, kann Phase 0 (Feinkonzept mit exakten Endpunkt- und
|
||||
> DB-Spezifikationen) beginnen.
|
||||
274
extension/background.js
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
'use strict';
|
||||
|
||||
// ── Cache ──────────────────────────────────────────────────────────────────
|
||||
let cachedEntries = null;
|
||||
let cacheTime = 0;
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 Minuten
|
||||
const faviconCache = new Map();
|
||||
let pendingClip = null;
|
||||
let refreshInFlight = null; // Single-Flight: verhindert parallele Refresh-Aufrufe (Rotation-Race)
|
||||
|
||||
async function getServerUrl() {
|
||||
const c = await new Promise(r => chrome.storage.local.get(['serverUrl'], r));
|
||||
return c.serverUrl || null;
|
||||
}
|
||||
|
||||
// ── Zugangstoken beschaffen (SSO-Refresh oder manueller Token) ──────────────
|
||||
// Reihenfolge: manueller Token (Erweitert) > SSO-Access-Token (mit Auto-Refresh).
|
||||
async function getAccessToken() {
|
||||
const cfg = await new Promise(r => chrome.storage.local.get(
|
||||
['serverUrl', 'apiToken', 'apiRefreshToken', 'apiRefreshExpiresAt'], r));
|
||||
if (!cfg.serverUrl) return null;
|
||||
if (cfg.apiToken) return cfg.apiToken; // manueller Token
|
||||
if (!cfg.apiRefreshToken) return null; // nicht angemeldet
|
||||
const sess = await chrome.storage.session.get(['accessToken', 'accessExpiresAt']);
|
||||
if (sess.accessToken && sess.accessExpiresAt && Date.now() < sess.accessExpiresAt - 30000) {
|
||||
return sess.accessToken;
|
||||
}
|
||||
// Nur EINEN Refresh gleichzeitig ausführen; parallele Aufrufer warten mit.
|
||||
if (!refreshInFlight) {
|
||||
refreshInFlight = refreshAccessToken(cfg).finally(() => { refreshInFlight = null; });
|
||||
}
|
||||
return await refreshInFlight;
|
||||
}
|
||||
|
||||
async function refreshAccessToken(cfg) {
|
||||
try {
|
||||
const body = new URLSearchParams();
|
||||
body.append('grant_type', 'refresh_token');
|
||||
body.append('refresh_token', cfg.apiRefreshToken);
|
||||
const res = await fetch(`${cfg.serverUrl}/api/vault/extension/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
// ungültig / abgelaufen / Reuse-Detection → SSO-Tokens verwerfen (Re-Login nötig)
|
||||
await chrome.storage.local.remove(['apiRefreshToken', 'apiRefreshExpiresAt']);
|
||||
await chrome.storage.session.remove(['accessToken', 'accessExpiresAt']);
|
||||
return null;
|
||||
}
|
||||
const data = await res.json();
|
||||
// Rotation: den NEUEN Refresh-Token speichern.
|
||||
await chrome.storage.local.set({
|
||||
apiRefreshToken: data.refresh_token,
|
||||
apiRefreshExpiresAt: Date.now() + (data.refresh_expires_in || 0) * 1000,
|
||||
});
|
||||
await chrome.storage.session.set({
|
||||
accessToken: data.access_token,
|
||||
accessExpiresAt: Date.now() + (data.expires_in || 0) * 1000,
|
||||
});
|
||||
return data.access_token;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
// Zentraler API-Aufruf: hängt Server-URL + gültigen Bearer an. null = nicht verfügbar.
|
||||
async function apiFetch(path, opts = {}) {
|
||||
const serverUrl = await getServerUrl();
|
||||
const token = await getAccessToken();
|
||||
if (!serverUrl || !token) return null;
|
||||
const headers = Object.assign({}, opts.headers, { 'Authorization': 'Bearer ' + token });
|
||||
return fetch(serverUrl + path, Object.assign({}, opts, { headers }));
|
||||
}
|
||||
|
||||
// ── Lock-Gate (serverseitig erzwungen; Client spiegelt nur) ─────────────────
|
||||
async function lockSettings() {
|
||||
return new Promise(resolve => chrome.storage.local.get(['lockDuration', 'lockEnabled'], resolve));
|
||||
}
|
||||
function lockRequired(s) { return !!s.lockEnabled; }
|
||||
function durationSecs(dur) {
|
||||
switch (String(dur)) {
|
||||
case '5': return 300;
|
||||
case '60': return 3600;
|
||||
case 'session': return 43200;
|
||||
case 'off': return 900;
|
||||
default: return 900;
|
||||
}
|
||||
}
|
||||
async function isUnlocked() {
|
||||
const s = await lockSettings();
|
||||
if (!lockRequired(s)) return true;
|
||||
const sess = await chrome.storage.session.get(['unlock']);
|
||||
const u = sess.unlock;
|
||||
if (!u) return false;
|
||||
if (u.sticky) return true;
|
||||
return !!u.until && Date.now() < u.until;
|
||||
}
|
||||
async function setUnlockedLocal(dur) {
|
||||
const unlock = (String(dur) === 'session') ? { sticky: true } : { until: Date.now() + durationSecs(dur) * 1000 };
|
||||
await chrome.storage.session.set({ unlock });
|
||||
}
|
||||
async function clearUnlocked() {
|
||||
await chrome.storage.session.remove('unlock');
|
||||
cachedEntries = null; cacheTime = 0; faviconCache.clear();
|
||||
try { await apiFetch('/api/vault/extension/lock', { method: 'POST' }); } catch (e) { /* ignore */ }
|
||||
}
|
||||
async function onServerLocked() {
|
||||
await chrome.storage.session.remove('unlock');
|
||||
cachedEntries = null; cacheTime = 0;
|
||||
}
|
||||
async function doUnlock(pin) {
|
||||
const s = await lockSettings();
|
||||
const dur = s.lockDuration || '15';
|
||||
try {
|
||||
const body = new URLSearchParams();
|
||||
body.append('pin', pin);
|
||||
body.append('duration_secs', String(durationSecs(dur)));
|
||||
const res = await apiFetch('/api/vault/extension/unlock', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!res) return { ok: false, error: 'Nicht konfiguriert.' };
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data.ok) { await setUnlockedLocal(dur); return { ok: true }; }
|
||||
return { ok: false, error: data.error || 'PIN falsch.', lockSecs: data.lock_secs || 0 };
|
||||
} catch (e) { return { ok: false, error: 'Verbindungsfehler.' }; }
|
||||
}
|
||||
|
||||
// ── Daten ───────────────────────────────────────────────────────────────────
|
||||
async function fetchEntries(force = false) {
|
||||
if (!(await isUnlocked())) return { entries: null, locked: true };
|
||||
const now = Date.now();
|
||||
if (!force && cachedEntries && (now - cacheTime) < CACHE_TTL) {
|
||||
return { entries: cachedEntries, locked: false };
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch('/api/vault/extension/entries');
|
||||
if (!res) return { entries: null, locked: false };
|
||||
if (res.status === 423) { await onServerLocked(); return { entries: null, locked: true }; }
|
||||
if (!res.ok) { cachedEntries = null; return { entries: null, locked: false }; }
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
cachedEntries = data.entries; cacheTime = Date.now();
|
||||
return { entries: cachedEntries, locked: false };
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return { entries: null, locked: false };
|
||||
}
|
||||
|
||||
async function fetchPassword(entryId) {
|
||||
if (!(await isUnlocked())) return null;
|
||||
try {
|
||||
const res = await apiFetch(`/api/vault/extension/entries/${entryId}/password`);
|
||||
if (!res) return null;
|
||||
if (res.status === 423) { await onServerLocked(); return null; }
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.ok ? data.password : null;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
async function fetchTotp(entryId) {
|
||||
if (!(await isUnlocked())) return null;
|
||||
try {
|
||||
const res = await apiFetch(`/api/vault/extension/entries/${entryId}/totp`);
|
||||
if (!res) return null;
|
||||
if (res.status === 423) { await onServerLocked(); return null; }
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.ok ? { code: data.code, remaining: data.remaining } : null;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
async function fetchFavicon(entryId) {
|
||||
if (faviconCache.has(entryId)) return faviconCache.get(entryId);
|
||||
try {
|
||||
const res = await apiFetch(`/api/vault/extension/entries/${entryId}/favicon?fetch=1`);
|
||||
if (!res || !res.ok) { faviconCache.set(entryId, null); return null; }
|
||||
const blob = await res.blob();
|
||||
const dataUrl = await new Promise(resolve => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(fr.result);
|
||||
fr.onerror = () => resolve(null);
|
||||
fr.readAsDataURL(blob);
|
||||
});
|
||||
faviconCache.set(entryId, dataUrl);
|
||||
return dataUrl;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function matchUrl(entryUrl, pageUrl) {
|
||||
if (!entryUrl) return false;
|
||||
let pHost;
|
||||
try { pHost = new URL(pageUrl).hostname.replace(/^www\./, ''); } catch { return false; }
|
||||
return String(entryUrl).split('\n').some(line => {
|
||||
const raw = line.trim();
|
||||
if (!raw) return false;
|
||||
try {
|
||||
const eu = raw.includes('://') ? raw : 'https://' + raw;
|
||||
let eHost = new URL(eu).hostname.replace(/^www\./, '');
|
||||
if (eHost.startsWith('*.')) eHost = eHost.slice(2);
|
||||
return pHost === eHost || pHost.endsWith('.' + eHost);
|
||||
} catch { return false; }
|
||||
});
|
||||
}
|
||||
|
||||
// ── Zwischenablage automatisch leeren (Offscreen) ───────────────────────────
|
||||
async function scheduleClipClear(text) {
|
||||
const cfg = await new Promise(r => chrome.storage.local.get(['clipClear'], r));
|
||||
if (cfg.clipClear === false) return;
|
||||
pendingClip = text || '';
|
||||
chrome.alarms.create('clipClear', { delayInMinutes: 0.5 });
|
||||
}
|
||||
async function clearClipboard() {
|
||||
try {
|
||||
if (!chrome.offscreen) return;
|
||||
const has = chrome.offscreen.hasDocument ? await chrome.offscreen.hasDocument() : false;
|
||||
if (!has) {
|
||||
await chrome.offscreen.createDocument({
|
||||
url: 'offscreen.html', reasons: ['CLIPBOARD'],
|
||||
justification: 'Zwischenablage nach dem Kopieren von Zugangsdaten leeren.',
|
||||
});
|
||||
}
|
||||
await chrome.runtime.sendMessage({ target: 'offscreen', type: 'CLIP_WRITE', text: '' });
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Status prüfen (Bearer) ──────────────────────────────────────────────────
|
||||
async function checkStatus() {
|
||||
const res = await apiFetch('/api/vault/extension/status');
|
||||
if (!res) return { ok: false, reason: 'not_configured' };
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data && typeof data.pin_enabled !== 'undefined') {
|
||||
await new Promise(r => chrome.storage.local.set({ lockEnabled: !!data.pin_enabled }, r));
|
||||
}
|
||||
return data;
|
||||
} catch { return { ok: false, reason: 'network_error' }; }
|
||||
}
|
||||
|
||||
// ── Message Handler ──────────────────────────────────────────────────────────
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (!msg || msg.target === 'offscreen') return;
|
||||
if (msg.type === 'GET_ENTRIES') {
|
||||
fetchEntries(msg.force).then(r => sendResponse({ entries: r.entries, locked: r.locked }));
|
||||
return true;
|
||||
}
|
||||
if (msg.type === 'GET_MATCHING_ENTRIES') {
|
||||
fetchEntries().then(r => {
|
||||
const matched = (r.entries || []).filter(e => matchUrl(e.url, msg.url));
|
||||
sendResponse({ entries: matched, locked: r.locked });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (msg.type === 'GET_PASSWORD') { fetchPassword(msg.id).then(password => sendResponse({ password })); return true; }
|
||||
if (msg.type === 'GET_TOTP') { fetchTotp(msg.id).then(result => sendResponse(result)); return true; }
|
||||
if (msg.type === 'GET_FAVICON') { fetchFavicon(msg.id).then(dataUrl => sendResponse({ dataUrl })); return true; }
|
||||
if (msg.type === 'GET_LOCK') {
|
||||
Promise.all([lockSettings(), isUnlocked()]).then(([s, unlocked]) => sendResponse({ required: lockRequired(s), unlocked }));
|
||||
return true;
|
||||
}
|
||||
if (msg.type === 'DO_UNLOCK') { doUnlock(msg.pin || '').then(sendResponse); return true; }
|
||||
if (msg.type === 'LOCK_NOW') { clearUnlocked().then(() => sendResponse({ ok: true })); return true; }
|
||||
if (msg.type === 'SCHEDULE_CLIP_CLEAR') { scheduleClipClear(msg.text || ''); sendResponse({ ok: true }); return true; }
|
||||
if (msg.type === 'CHECK_STATUS') { checkStatus().then(sendResponse); return true; }
|
||||
if (msg.type === 'CLEAR_CACHE') { cachedEntries = null; cacheTime = 0; faviconCache.clear(); sendResponse({ ok: true }); return true; }
|
||||
});
|
||||
|
||||
// Cache alle 5 Minuten leeren; Zwischenablage-Clear nach Timeout.
|
||||
chrome.alarms.create('clearCache', { periodInMinutes: 5 });
|
||||
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
if (alarm.name === 'clearCache') { cachedEntries = null; cacheTime = 0; faviconCache.clear(); return; }
|
||||
if (alarm.name === 'clipClear') { await clearClipboard(); pendingClip = null; }
|
||||
});
|
||||
551
extension/content.js
Normal file
|
|
@ -0,0 +1,551 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* OpenNIT Vault – Content-Script
|
||||
* Robuste Erkennung von Passwort-, Benutzer-/E-Mail- und TOTP-Feldern
|
||||
* inkl. Shadow-DOM, dynamischen Formularen, mehrstufigen Logins und
|
||||
* segmentierten OTP-Eingaben. Autofill via nativem Value-Setter + Events
|
||||
* (framework-kompatibel: React/Vue/Angular).
|
||||
*/
|
||||
if (!window.__vaultInjected) {
|
||||
window.__vaultInjected = true;
|
||||
|
||||
const DROPDOWN_ID = '__vault_dropdown__';
|
||||
let appLabel = 'Vault';
|
||||
let currentField = null;
|
||||
let showGen = 0;
|
||||
|
||||
// ── Heuristik-Muster ────────────────────────────────────────────────────────
|
||||
const RE_USER = /(user(name|id)?|login|logon|sign[-_ ]?in|account|konto|benutzer|kennung|anmeld|e[-_ ]?mail|email|mail|uid|userid|handle|identifier|ident\b|loginid)/i;
|
||||
const RE_USER_NEG = /(search|suche|query|coupon|promo|voucher|gift|zip|postal|plz|phone|tel|mobile|firstname|lastname|first[-_ ]?name|last[-_ ]?name|vorname|nachname|street|strasse|address|adresse|city|stadt|country|land|company|firma|captcha|amount|menge|quantity|qty)/i;
|
||||
const RE_PASS = /(pass(word|wort)?|pwd|passwd|kennwort|passphrase)/i;
|
||||
const RE_PASS_NEG = /(hint|frage|question|reminder|recovery|forgot|vergessen)/i;
|
||||
const RE_OTP = /(otp|totp|2fa|mfa|one[-_ ]?time|einmal|verification|verify|verifizier|authenticat|auth[-_ ]?code|security[-_ ]?code|sms[-_ ]?code|passcode|one_?time_?code|2[-_ ]?step|two[-_ ]?factor|bestätigungscode|einmalkennwort|einmalpasswort)/i;
|
||||
const RE_CODEONLY = /(\b|_)(code|pin|token)(\b|_)/i;
|
||||
|
||||
// ── kleine Helfer ───────────────────────────────────────────────────────────
|
||||
function lc(s) { return String(s || '').toLowerCase(); }
|
||||
function esc(s) { return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
function vaultHue(s) { s = String(s || '?'); let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }
|
||||
function vaultClipCopy(text) {
|
||||
navigator.clipboard.writeText(text).catch(() => {});
|
||||
try { chrome.runtime.sendMessage({ type: 'SCHEDULE_CLIP_CLEAR', text: text }); } catch (e) { /* ignore */ }
|
||||
}
|
||||
function attr(el, n) { try { return el.getAttribute(n) || ''; } catch { return ''; } }
|
||||
function ac(el) { return lc(attr(el, 'autocomplete')); }
|
||||
|
||||
function isVisible(el) {
|
||||
if (!el) return false;
|
||||
if (el.disabled || el.readOnly) return false;
|
||||
if (lc(el.type) === 'hidden') return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 4 || r.height < 4) return false;
|
||||
const s = getComputedStyle(el);
|
||||
if (s.display === 'none' || s.visibility === 'hidden' || s.visibility === 'collapse') return false;
|
||||
if (parseFloat(s.opacity || '1') === 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function labelText(el) {
|
||||
const parts = [];
|
||||
try {
|
||||
if (el.id) {
|
||||
const sel = (window.CSS && CSS.escape) ? CSS.escape(el.id) : el.id;
|
||||
const l = document.querySelector('label[for="' + sel + '"]');
|
||||
if (l) parts.push(l.textContent);
|
||||
}
|
||||
} catch {}
|
||||
const wrap = el.closest ? el.closest('label') : null;
|
||||
if (wrap) parts.push(wrap.textContent);
|
||||
const lb = attr(el, 'aria-labelledby');
|
||||
if (lb) lb.split(/\s+/).forEach(id => { const n = document.getElementById(id); if (n) parts.push(n.textContent); });
|
||||
return parts.join(' ').slice(0, 200);
|
||||
}
|
||||
|
||||
function sig(el) {
|
||||
return lc([
|
||||
el.name, el.id, attr(el, 'autocomplete'), el.placeholder,
|
||||
attr(el, 'aria-label'), el.title, attr(el, 'data-testid'),
|
||||
attr(el, 'ng-model'), el.className, labelText(el),
|
||||
].join(' '));
|
||||
}
|
||||
|
||||
function isTextLike(el) {
|
||||
if (!el || el.tagName !== 'INPUT') return false;
|
||||
return ['text', 'email', 'tel', 'search', 'url', 'number', ''].includes(lc(el.type || 'text'));
|
||||
}
|
||||
|
||||
// ── Feld-Klassifikation ─────────────────────────────────────────────────────
|
||||
function isPasswordField(el) {
|
||||
if (!el || el.tagName !== 'INPUT') return false;
|
||||
if (lc(el.type) === 'password') return true;
|
||||
const a = ac(el);
|
||||
if (a.includes('current-password') || a.includes('new-password')) return true;
|
||||
// sichtbar geschaltetes Passwortfeld (type=text)
|
||||
if (isTextLike(el)) {
|
||||
const s = sig(el);
|
||||
if (RE_PASS.test(s) && !RE_PASS_NEG.test(s) && !RE_USER.test(lc(el.name + ' ' + el.id))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isOtpField(el) {
|
||||
if (!el || el.tagName !== 'INPUT') return false;
|
||||
const t = lc(el.type);
|
||||
if (['password', 'checkbox', 'radio', 'submit', 'button', 'file', 'hidden', 'range', 'color', 'date'].includes(t)) return false;
|
||||
if (ac(el).includes('one-time-code')) return true;
|
||||
const s = sig(el);
|
||||
const ml = parseInt(attr(el, 'maxlength') || '0', 10);
|
||||
const pat = lc(attr(el, 'pattern'));
|
||||
const numeric = lc(el.inputMode || '') === 'numeric' || pat.includes('0-9') || pat.includes('\\d') || t === 'number' || t === 'tel';
|
||||
if (RE_OTP.test(s)) return true;
|
||||
if (RE_CODEONLY.test(s) && (numeric || (ml > 0 && ml <= 8))) return true;
|
||||
// segmentierte OTP-Eingabe (mehrere 1-Zeichen-Felder)
|
||||
if (ml === 1 && numeric) return segmentGroup(el).length >= 4;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isUsernameField(el) {
|
||||
if (!el || el.tagName !== 'INPUT') return false;
|
||||
const t = lc(el.type || 'text');
|
||||
if (['password', 'submit', 'button', 'hidden', 'checkbox', 'radio', 'file', 'image', 'range', 'color', 'date', 'datetime-local', 'month', 'week', 'time'].includes(t)) return false;
|
||||
if (isOtpField(el)) return false;
|
||||
const a = ac(el);
|
||||
if (a.includes('username') || a === 'email') return true;
|
||||
if (t === 'email') return true;
|
||||
const s = sig(el);
|
||||
return RE_USER.test(s) && !RE_USER_NEG.test(s);
|
||||
}
|
||||
|
||||
function isLoginField(el) { return isPasswordField(el) || isUsernameField(el) || isOtpField(el); }
|
||||
function fieldKind(el) {
|
||||
if (isPasswordField(el)) return 'password';
|
||||
if (isOtpField(el)) return 'otp';
|
||||
if (isUsernameField(el)) return 'username';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Shadow-DOM-fähige Feldsammlung ──────────────────────────────────────────
|
||||
function collectInputs(container) {
|
||||
const out = [];
|
||||
const visit = (root) => {
|
||||
let nodes;
|
||||
try { nodes = root.querySelectorAll('input, textarea'); } catch { nodes = []; }
|
||||
nodes.forEach(n => out.push(n));
|
||||
let all;
|
||||
try { all = root.querySelectorAll('*'); } catch { all = []; }
|
||||
all.forEach(n => { if (n.shadowRoot) visit(n.shadowRoot); });
|
||||
};
|
||||
visit(container || document);
|
||||
return out;
|
||||
}
|
||||
|
||||
function scopeOf(field) {
|
||||
const form = field.closest ? field.closest('form') : null;
|
||||
if (form) return form;
|
||||
const root = field.getRootNode ? field.getRootNode() : null;
|
||||
if (root && root.host && root.host.closest) {
|
||||
const f = root.host.closest('form');
|
||||
if (f) return f;
|
||||
}
|
||||
return document.body;
|
||||
}
|
||||
|
||||
function segmentGroup(el) {
|
||||
const parent = el.parentElement;
|
||||
if (!parent) return [el];
|
||||
const sibs = [...parent.querySelectorAll('input')].filter(i => parseInt(attr(i, 'maxlength') || '0', 10) === 1);
|
||||
return sibs.length >= 4 ? sibs : [el];
|
||||
}
|
||||
|
||||
function findUsernameField(ref) {
|
||||
const inputs = collectInputs(scopeOf(ref)).filter(isVisible);
|
||||
const idx = inputs.indexOf(ref);
|
||||
for (let i = idx - 1; i >= 0; i--) if (isUsernameField(inputs[i])) return inputs[i];
|
||||
for (let i = idx + 1; i < inputs.length; i++) if (isUsernameField(inputs[i])) return inputs[i];
|
||||
// positionaler Fallback: Textfeld direkt vor dem Passwort
|
||||
for (let i = idx - 1; i >= 0; i--) if (isTextLike(inputs[i]) && !isOtpField(inputs[i])) return inputs[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
function findPasswordField(ref) {
|
||||
const inputs = collectInputs(scopeOf(ref));
|
||||
const vis = inputs.filter(isVisible);
|
||||
return vis.find(isPasswordField) || inputs.find(isPasswordField) || null;
|
||||
}
|
||||
|
||||
function findOtpFields(ref) {
|
||||
return collectInputs(scopeOf(ref)).filter(el => isVisible(el) && isOtpField(el));
|
||||
}
|
||||
|
||||
// ── URL-Matching ────────────────────────────────────────────────────────────
|
||||
function normalizeHost(raw) {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const s = raw.includes('://') ? raw : 'https://' + raw;
|
||||
return new URL(s).hostname.replace(/^www\./, '').toLowerCase();
|
||||
} catch { return lc(raw).replace(/^www\./, ''); }
|
||||
}
|
||||
function matchUrl(entryUrls, pageUrl) {
|
||||
const pageHost = normalizeHost(pageUrl);
|
||||
if (!pageHost) return false;
|
||||
const urls = typeof entryUrls === 'string' ? entryUrls.split('\n') : [entryUrls];
|
||||
return urls.some(u => {
|
||||
let eh = normalizeHost((u || '').trim());
|
||||
if (!eh) return false;
|
||||
if (eh.startsWith('*.')) eh = eh.slice(2);
|
||||
return pageHost === eh || pageHost.endsWith('.' + eh);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Events ──────────────────────────────────────────────────────────────────
|
||||
function init() {
|
||||
document.addEventListener('focusin', onFocusIn, true);
|
||||
document.addEventListener('focusout', onFocusOut, true);
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
document.addEventListener('keydown', onKeyDown, true);
|
||||
document.addEventListener('click', onDocClick, true);
|
||||
window.addEventListener('scroll', repositionDrop, true);
|
||||
window.addEventListener('resize', repositionDrop, true);
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg && msg.type === 'VAULT_FILL') {
|
||||
fillFromPopup(msg);
|
||||
sendResponse({ ok: true });
|
||||
return true;
|
||||
}
|
||||
});
|
||||
chrome.runtime.sendMessage({ type: 'CHECK_STATUS' }, resp => { if (resp && resp.app_name) appLabel = resp.app_name; });
|
||||
}
|
||||
|
||||
// Vom Popup angestoßenes Ausfüllen (ohne fokussiertes Feld): bestes
|
||||
// Passwort-/Benutzerfeld der Seite suchen und befüllen.
|
||||
function fillFromPopup(msg) {
|
||||
const inputs = collectInputs(document).filter(isVisible);
|
||||
const passField = inputs.find(isPasswordField) || null;
|
||||
let userField = passField ? findUsernameField(passField) : null;
|
||||
if (!userField) userField = inputs.find(isUsernameField) || null;
|
||||
|
||||
if (userField && msg.username) setFieldValue(userField, msg.username);
|
||||
if (passField && msg.password) setFieldValue(passField, msg.password);
|
||||
else if (msg.password) chrome.storage.local.set({ __pendingFill: { id: msg.id, pw: msg.password, user: msg.username || '', ts: Date.now() } });
|
||||
|
||||
if (msg.has_totp && msg.id != null) {
|
||||
const otps = findOtpFields(passField || userField || document.body);
|
||||
chrome.runtime.sendMessage({ type: 'GET_TOTP', id: msg.id }, t => {
|
||||
if (!t || !t.code) return;
|
||||
if (otps.length) distributeOtp(otps, t.code);
|
||||
vaultClipCopy(t.code);
|
||||
showTotpNotification(t.code, t.remaining);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onFocusIn(e) { maybeShow(e.target); }
|
||||
function onPointerDown(e) {
|
||||
const drop = document.getElementById(DROPDOWN_ID);
|
||||
if (drop && drop.contains(e.target)) return;
|
||||
maybeShow(e.target);
|
||||
}
|
||||
function maybeShow(el) {
|
||||
if (!isLoginField(el) || !isVisible(el)) return;
|
||||
currentField = el;
|
||||
showSuggestions(el);
|
||||
}
|
||||
function onFocusOut(e) {
|
||||
const blurred = e.target;
|
||||
setTimeout(() => {
|
||||
const active = document.activeElement;
|
||||
const drop = document.getElementById(DROPDOWN_ID);
|
||||
if (active === blurred || active === currentField || (drop && drop.contains(active))) return;
|
||||
hideDrop();
|
||||
currentField = null;
|
||||
}, 200);
|
||||
}
|
||||
function onDocClick(e) {
|
||||
const drop = document.getElementById(DROPDOWN_ID);
|
||||
if (drop && drop.contains(e.target)) return;
|
||||
if (e.target === currentField) return;
|
||||
hideDrop();
|
||||
}
|
||||
function onKeyDown(e) {
|
||||
const drop = document.getElementById(DROPDOWN_ID);
|
||||
if (!drop) return;
|
||||
const items = [...drop.querySelectorAll('.vi')];
|
||||
if (!items.length) return;
|
||||
let idx = items.findIndex(i => i.classList.contains('selected'));
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(items, idx + 1); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(items, idx - 1); }
|
||||
else if (e.key === 'Enter' && idx >= 0) { e.preventDefault(); items[idx].click(); }
|
||||
else if (e.key === 'Escape') { hideDrop(); currentField = null; }
|
||||
}
|
||||
function setSelected(items, idx) {
|
||||
items.forEach(i => i.classList.remove('selected'));
|
||||
const next = items[Math.max(0, Math.min(idx, items.length - 1))];
|
||||
if (next) { next.classList.add('selected'); next.scrollIntoView({ block: 'nearest' }); }
|
||||
}
|
||||
|
||||
// ── Vorschläge ──────────────────────────────────────────────────────────────
|
||||
function showSuggestions(field) {
|
||||
const gen = ++showGen;
|
||||
const mode = fieldKind(field) === 'otp' ? 'otp' : 'login';
|
||||
chrome.runtime.sendMessage({ type: 'GET_MATCHING_ENTRIES', url: location.href }, resp => {
|
||||
if (gen !== showGen) return;
|
||||
let entries = (resp && resp.entries || []).filter(e => matchUrl(e.url, location.href));
|
||||
if (mode === 'otp') entries = entries.filter(e => e.has_totp);
|
||||
if (!entries.length) { hideDrop(); return; }
|
||||
if (document.contains(field) && isVisible(field)) renderDrop(field, entries, mode);
|
||||
});
|
||||
}
|
||||
|
||||
function repositionDrop() {
|
||||
const drop = document.getElementById(DROPDOWN_ID);
|
||||
if (!drop || !currentField) return;
|
||||
const r = currentField.getBoundingClientRect();
|
||||
if (r.width === 0) { hideDrop(); return; }
|
||||
drop.style.top = (r.bottom + 2) + 'px';
|
||||
drop.style.left = r.left + 'px';
|
||||
drop.style.width = Math.max(r.width, 300) + 'px';
|
||||
}
|
||||
|
||||
function renderDrop(field, entries, mode) {
|
||||
hideDrop();
|
||||
const rect = field.getBoundingClientRect();
|
||||
if (rect.width === 0) return;
|
||||
|
||||
const drop = document.createElement('div');
|
||||
drop.id = DROPDOWN_ID;
|
||||
Object.assign(drop.style, {
|
||||
position: 'fixed', top: (rect.bottom + 4) + 'px', left: rect.left + 'px',
|
||||
width: Math.max(rect.width, 300) + 'px', background: '#fff', border: '1px solid #e3e6ef',
|
||||
borderRadius: '12px', boxShadow: '0 10px 32px rgba(31,35,48,.20)', zIndex: '2147483647',
|
||||
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif', fontSize: '13px',
|
||||
overflow: 'hidden', maxHeight: '320px', overflowY: 'auto', color: '#1f2330',
|
||||
});
|
||||
|
||||
// Hover-/Auswahl-Highlight (scoped auf unser Dropdown – page-safe)
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.textContent = '#' + DROPDOWN_ID + ' .vi:hover,#' + DROPDOWN_ID + ' .vi.selected{background:#f5f6fb !important;}';
|
||||
drop.appendChild(styleEl);
|
||||
|
||||
const hd = document.createElement('div');
|
||||
Object.assign(hd.style, {
|
||||
padding: '9px 13px', background: 'linear-gradient(135deg,#4f46e5 0%,#5b6ee8 45%,#3c8dbc 100%)',
|
||||
color: '#fff', fontWeight: '700', fontSize: '10.5px',
|
||||
display: 'flex', alignItems: 'center', gap: '7px', letterSpacing: '.05em', textTransform: 'uppercase',
|
||||
});
|
||||
hd.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> '
|
||||
+ esc(appLabel) + (mode === 'otp' ? ' · 2FA' : ' · Vault');
|
||||
drop.appendChild(hd);
|
||||
|
||||
entries.forEach(entry => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'vi';
|
||||
item.dataset.id = entry.id;
|
||||
Object.assign(item.style, {
|
||||
padding: '9px 13px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '10px',
|
||||
borderBottom: '1px solid #f1f3f5', background: '#fff', transition: 'background .1s',
|
||||
});
|
||||
|
||||
const _hue = vaultHue(entry.title || '?');
|
||||
const favSpan = document.createElement('span');
|
||||
Object.assign(favSpan.style, {
|
||||
width: '22px', height: '22px', borderRadius: '6px', display: 'inline-flex', alignItems: 'center',
|
||||
justifyContent: 'center', fontSize: '11px', fontWeight: '700', flexShrink: '0', overflow: 'hidden',
|
||||
background: 'hsl(' + _hue + ',52%,90%)', color: 'hsl(' + _hue + ',55%,38%)',
|
||||
});
|
||||
favSpan.textContent = (entry.title || '?').charAt(0).toUpperCase();
|
||||
// Serverseitig gecachtes Favicon nachladen (kein externer Call)
|
||||
if (entry.has_favicon) {
|
||||
chrome.runtime.sendMessage({ type: 'GET_FAVICON', id: entry.id }, r => {
|
||||
if (r && r.dataUrl) {
|
||||
favSpan.style.background = '#eef0f7';
|
||||
favSpan.innerHTML = '<img src="' + r.dataUrl + '" alt="" style="width:16px;height:16px;object-fit:contain;">';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const team = entry.team_name
|
||||
? '<span style="font-size:9px;background:#ede9fe;color:#6d28d9;border-radius:5px;padding:1.5px 6px;white-space:nowrap;flex-shrink:0;font-weight:700;">' + esc(entry.team_name) + '</span>'
|
||||
: '';
|
||||
const sub = mode === 'otp'
|
||||
? '<span style="color:#4f46e5;font-size:11px;font-weight:600;">2FA-Code einfügen</span>'
|
||||
: '<div style="color:#79839a;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + (esc(entry.username) || '<em style="color:#aab2c3">Kein Benutzername</em>') + '</div>';
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.style.cssText = 'flex:1;min-width:0;';
|
||||
info.innerHTML = '<div style="font-weight:600;color:#1f2330;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12.5px;">'
|
||||
+ esc(entry.title) + '</div>' + sub;
|
||||
|
||||
item.appendChild(favSpan);
|
||||
item.appendChild(info);
|
||||
if (team) {
|
||||
const t = document.createElement('span');
|
||||
t.innerHTML = team;
|
||||
item.appendChild(t.firstChild);
|
||||
}
|
||||
|
||||
item.addEventListener('mouseenter', () => {
|
||||
[...drop.querySelectorAll('.vi')].forEach(i => i.classList.remove('selected'));
|
||||
item.classList.add('selected');
|
||||
});
|
||||
|
||||
// optionaler 2FA-Chip im Login-Modus
|
||||
if (mode === 'login' && entry.has_totp) {
|
||||
const chip = document.createElement('button');
|
||||
Object.assign(chip.style, {
|
||||
background: '#e0f2fe', border: '1px solid #bae0fd', borderRadius: '6px', padding: '2px 7px',
|
||||
fontSize: '9px', fontWeight: '700', color: '#0369a1', cursor: 'pointer', flexShrink: '0',
|
||||
letterSpacing: '.03em', fontFamily: 'inherit',
|
||||
});
|
||||
chip.textContent = '2FA';
|
||||
chip.title = '2FA-Code kopieren';
|
||||
chip.addEventListener('mousedown', ev => {
|
||||
ev.preventDefault(); ev.stopPropagation();
|
||||
chrome.runtime.sendMessage({ type: 'GET_TOTP', id: entry.id }, r => {
|
||||
if (r && r.code) { vaultClipCopy(r.code); showTotpNotification(r.code, r.remaining); }
|
||||
});
|
||||
});
|
||||
item.appendChild(chip);
|
||||
}
|
||||
|
||||
item.addEventListener('mousedown', ev => {
|
||||
if (ev.target.tagName === 'BUTTON') return;
|
||||
ev.preventDefault(); ev.stopPropagation();
|
||||
if (mode === 'otp') fillOtp(field, entry);
|
||||
else fillEntry(entry, field);
|
||||
hideDrop();
|
||||
});
|
||||
drop.appendChild(item);
|
||||
});
|
||||
|
||||
const ft = document.createElement('div');
|
||||
Object.assign(ft.style, { padding: '5px 12px', color: '#79839a', fontSize: '10px', textAlign: 'center', background: '#f6f7fb', borderTop: '1px solid #edeff4' });
|
||||
ft.innerHTML = '↑↓ Navigieren · Enter Auswählen · Esc Schließen';
|
||||
drop.appendChild(ft);
|
||||
|
||||
document.documentElement.appendChild(drop);
|
||||
}
|
||||
|
||||
function hideDrop() {
|
||||
const d = document.getElementById(DROPDOWN_ID);
|
||||
if (d) d.remove();
|
||||
}
|
||||
|
||||
// ── Befüllen ────────────────────────────────────────────────────────────────
|
||||
async function fillEntry(entry, focused) {
|
||||
let pw = '';
|
||||
try { const r = await chrome.runtime.sendMessage({ type: 'GET_PASSWORD', id: entry.id }); pw = (r && r.password) || ''; } catch {}
|
||||
|
||||
const kind = fieldKind(focused) || 'username';
|
||||
let userField = null, passField = null;
|
||||
if (kind === 'password') { passField = focused; userField = findUsernameField(focused); }
|
||||
else { userField = focused; passField = findPasswordField(focused); }
|
||||
|
||||
if (userField && entry.username) setFieldValue(userField, entry.username);
|
||||
if (passField) setFieldValue(passField, pw);
|
||||
else chrome.storage.local.set({ __pendingFill: { id: entry.id, pw: pw, user: entry.username || '', ts: Date.now() } });
|
||||
|
||||
if (entry.has_totp) {
|
||||
const otps = findOtpFields(passField || userField || focused);
|
||||
chrome.runtime.sendMessage({ type: 'GET_TOTP', id: entry.id }, t => {
|
||||
if (!t || !t.code) return;
|
||||
if (otps.length) distributeOtp(otps, t.code);
|
||||
vaultClipCopy(t.code);
|
||||
showTotpNotification(t.code, t.remaining);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function fillOtp(field, entry) {
|
||||
chrome.runtime.sendMessage({ type: 'GET_TOTP', id: entry.id }, t => {
|
||||
if (!t || !t.code) return;
|
||||
const group = segmentGroup(field);
|
||||
if (group.length >= 4) distributeOtp(group, t.code);
|
||||
else setFieldValue(field, t.code);
|
||||
vaultClipCopy(t.code);
|
||||
showTotpNotification(t.code, t.remaining);
|
||||
});
|
||||
}
|
||||
|
||||
function distributeOtp(fields, code) {
|
||||
const digits = String(code).replace(/\s+/g, '').split('');
|
||||
if (fields.length >= digits.length && fields.length > 1) {
|
||||
fields.forEach((f, i) => setFieldValue(f, digits[i] || ''));
|
||||
const last = fields[Math.min(digits.length, fields.length) - 1];
|
||||
if (last) last.focus({ preventScroll: true });
|
||||
} else {
|
||||
setFieldValue(fields[0], String(code).replace(/\s+/g, ''));
|
||||
}
|
||||
}
|
||||
|
||||
function setFieldValue(field, value) {
|
||||
try {
|
||||
field.focus({ preventScroll: true });
|
||||
const proto = (typeof HTMLTextAreaElement !== 'undefined' && field instanceof HTMLTextAreaElement)
|
||||
? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||
const setter = Object.getOwnPropertyDescriptor(proto, 'value');
|
||||
if (setter && setter.set) setter.set.call(field, value); else field.value = value;
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
field.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
field.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true }));
|
||||
field.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
|
||||
field.dispatchEvent(new Event('blur', { bubbles: true }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Mehrstufiger Login: Passwort/User nach dem Erscheinen befüllen ───────────
|
||||
const _obs = new MutationObserver(() => {
|
||||
chrome.storage.local.get(['__pendingFill'], result => {
|
||||
const p = result.__pendingFill;
|
||||
if (!p || Date.now() - p.ts > 30000) return;
|
||||
const pw = collectInputs(document).filter(f => isVisible(f) && isPasswordField(f));
|
||||
if (!pw.length) return;
|
||||
pw.forEach(f => setFieldValue(f, p.pw));
|
||||
if (p.user) {
|
||||
const uf = findUsernameField(pw[0]);
|
||||
if (uf && !uf.value) setFieldValue(uf, p.user);
|
||||
}
|
||||
chrome.storage.local.remove('__pendingFill');
|
||||
});
|
||||
});
|
||||
try { _obs.observe(document.documentElement, { childList: true, subtree: true }); } catch {}
|
||||
|
||||
// ── TOTP-Benachrichtigung (unten rechts) ────────────────────────────────────
|
||||
function showTotpNotification(code, remaining) {
|
||||
const ID = '__vault_totp_notif__';
|
||||
const old = document.getElementById(ID);
|
||||
if (old) old.remove();
|
||||
|
||||
const formatted = String(code).length === 6 ? code.slice(0, 3) + ' ' + code.slice(3) : code;
|
||||
const notif = document.createElement('div');
|
||||
notif.id = ID;
|
||||
Object.assign(notif.style, {
|
||||
position: 'fixed', bottom: '18px', right: '18px', background: '#212529', color: '#fff',
|
||||
padding: '10px 14px', borderRadius: '8px', fontSize: '12px',
|
||||
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', zIndex: '2147483647',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,.35)', display: 'flex', flexDirection: 'column', gap: '6px',
|
||||
minWidth: '180px', cursor: 'pointer',
|
||||
});
|
||||
notif.innerHTML =
|
||||
'<div style="display:flex;align-items:center;gap:7px;"><span style="font-size:14px;">🔑</span>'
|
||||
+ '<span style="color:#adb5bd;font-size:10px;flex:1;">2FA-Code kopiert</span></div>'
|
||||
+ '<div style="font-family:monospace;font-size:18px;font-weight:700;letter-spacing:.12em;">' + esc(formatted) + '</div>'
|
||||
+ '<div style="display:flex;align-items:center;gap:7px;"><div style="flex:1;height:3px;background:rgba(255,255,255,.15);border-radius:2px;overflow:hidden;">'
|
||||
+ '<div id="__vault_totp_bar" style="height:100%;background:#28a745;width:' + (remaining / 30 * 100) + '%;transition:width 1s linear;"></div></div>'
|
||||
+ '<span id="__vault_totp_t" style="font-size:10px;color:#adb5bd;min-width:22px;text-align:right;">' + remaining + 's</span></div>';
|
||||
document.documentElement.appendChild(notif);
|
||||
|
||||
let secs = remaining;
|
||||
const iv = setInterval(() => {
|
||||
secs--;
|
||||
const t = document.getElementById('__vault_totp_t');
|
||||
const bar = document.getElementById('__vault_totp_bar');
|
||||
if (secs <= 0 || !t) { clearInterval(iv); notif.style.transition = 'opacity .4s'; notif.style.opacity = '0'; setTimeout(() => notif.remove(), 400); return; }
|
||||
t.textContent = secs + 's';
|
||||
if (bar) { bar.style.width = (secs / 30 * 100) + '%'; if (secs < 10) bar.style.background = '#dc3545'; }
|
||||
}, 1000);
|
||||
notif.addEventListener('click', () => { clearInterval(iv); notif.remove(); });
|
||||
}
|
||||
|
||||
init();
|
||||
}
|
||||
BIN
extension/icon128.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
extension/icon16.png
Normal file
|
After Width: | Height: | Size: 618 B |
BIN
extension/icon32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
extension/icon48.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
48
extension/manifest.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "OpenNIT Vault",
|
||||
"version": "2.4.1",
|
||||
"description": "OpenNIT Vault – Passwort-Manager mit Autofill für Benutzer-, Passwort- und 2FA-Felder direkt im Browser.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
"activeTab",
|
||||
"scripting",
|
||||
"alarms",
|
||||
"offscreen",
|
||||
"identity"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "OpenNIT Vault",
|
||||
"default_icon": {
|
||||
"16": "icon16.png",
|
||||
"32": "icon32.png"
|
||||
}
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"js": [
|
||||
"content.js"
|
||||
],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"options_ui": {
|
||||
"page": "options.html",
|
||||
"open_in_tab": true
|
||||
},
|
||||
"icons": {
|
||||
"16": "icon16.png",
|
||||
"48": "icon48.png",
|
||||
"128": "icon128.png"
|
||||
}
|
||||
}
|
||||
1
extension/offscreen.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body><textarea id="t"></textarea><script src="offscreen.js"></script></body></html>
|
||||
17
extension/offscreen.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
'use strict';
|
||||
chrome.runtime.onMessage.addListener((msg) => {
|
||||
if (!msg || msg.target !== 'offscreen') return;
|
||||
if (msg.type === 'CLIP_WRITE') {
|
||||
const text = msg.text || '';
|
||||
// Bevorzugt die Clipboard-API; Fallback über execCommand.
|
||||
Promise.resolve()
|
||||
.then(() => navigator.clipboard.writeText(text))
|
||||
.catch(() => {
|
||||
const ta = document.getElementById('t');
|
||||
ta.value = text || ' ';
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch (e) { /* ignore */ }
|
||||
ta.value = '';
|
||||
});
|
||||
}
|
||||
});
|
||||
304
extension/options.html
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>OpenNIT Vault – Einstellungen</title>
|
||||
<style>
|
||||
/* ── Reset & Base ──────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: "Source Sans Pro", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
color: #212529;
|
||||
background: #f4f6f9;
|
||||
margin: 0; padding: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Main navbar (AdminLTE style) ─────────────────────────────────────── */
|
||||
.main-header {
|
||||
background: #343a40;
|
||||
border-bottom: 3px solid #007bff;
|
||||
padding: 0 1.25rem;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.25);
|
||||
}
|
||||
.brand-logo {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
overflow: hidden; flex-shrink: 0;
|
||||
}
|
||||
.brand-logo img { width: 26px; height: 26px; object-fit: contain; }
|
||||
.brand-text {
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.brand-sep { color: rgba(255,255,255,.35); margin: 0 2px; font-weight: 300; }
|
||||
.brand-sub { color: #adb5bd; font-weight: 400; font-size: .95rem; }
|
||||
.header-user {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
color: #dee2e6;
|
||||
font-size: .82rem;
|
||||
}
|
||||
.user-badge {
|
||||
background: rgba(255,255,255,.1);
|
||||
border: 1px solid rgba(255,255,255,.15);
|
||||
border-radius: 20px;
|
||||
padding: 3px 10px 3px 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: .78rem;
|
||||
}
|
||||
.user-dot { width: 7px; height: 7px; background: #28a745; border-radius: 50%; }
|
||||
|
||||
/* ── Content wrapper ──────────────────────────────────────────────────── */
|
||||
.content-wrapper { max-width: 680px; margin: 0 auto; padding: 1.5rem 1.25rem; }
|
||||
|
||||
/* ── Card (AdminLTE .card) ───────────────────────────────────────────── */
|
||||
.card {
|
||||
background: #fff;
|
||||
border: none;
|
||||
border-radius: .35rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.card-header {
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
padding: .75rem 1.25rem;
|
||||
border-radius: .35rem .35rem 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
}
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
font-size: .9rem;
|
||||
font-weight: 600;
|
||||
color: #343a40;
|
||||
line-height: 1;
|
||||
}
|
||||
.card-header .card-tools { margin-left: auto; }
|
||||
.card-body { padding: 1.25rem; }
|
||||
|
||||
/* ── Form controls ───────────────────────────────────────────────────── */
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
.form-group:last-child { margin-bottom: 0; }
|
||||
label.control-label {
|
||||
display: block;
|
||||
font-size: .78rem;
|
||||
font-weight: 700;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
margin-bottom: .35rem;
|
||||
}
|
||||
.form-control {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: .45rem .75rem;
|
||||
font-size: .9rem;
|
||||
color: #495057;
|
||||
background: #fff;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: .25rem;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #80bdff;
|
||||
box-shadow: 0 0 0 .2rem rgba(0,123,255,.25);
|
||||
}
|
||||
.form-text { font-size: .78rem; color: #6c757d; margin-top: .25rem; line-height: 1.5; }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .35rem;
|
||||
padding: .4rem .85rem;
|
||||
font-size: .875rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
border-radius: .25rem;
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s, color .15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-primary { background: #007bff; border-color: #007bff; color: #fff; }
|
||||
.btn-primary:hover { background: #0069d9; border-color: #0062cc; }
|
||||
.btn-default { background: #fff; border-color: #ced4da; color: #212529; }
|
||||
.btn-default:hover { background: #f8f9fa; border-color: #adb5bd; }
|
||||
.btn-sm { padding: .28rem .65rem; font-size: .8rem; }
|
||||
.btn-group { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin-top: .75rem; }
|
||||
|
||||
/* ── Alerts ──────────────────────────────────────────────────────────── */
|
||||
.alert { padding: .65rem 1rem; border-radius: .25rem; font-size: .85rem; margin-top: .75rem; border: 1px solid transparent; }
|
||||
.alert-success { background: #d4edda; color: #155724; border-color: #c3e6cb; }
|
||||
.alert-danger { background: #f8d7da; color: #721c24; border-color: #f5c6cb; }
|
||||
.alert-info { background: #d1ecf1; color: #0c5460; border-color: #bee5eb; }
|
||||
.save-ok { color: #28a745; font-size: .82rem; font-weight: 600; display: inline-flex; align-items: center; gap: 4px; }
|
||||
|
||||
/* ── Spinner ─────────────────────────────────────────────────────────── */
|
||||
.fa-spin-sm {
|
||||
display: inline-block;
|
||||
width: 12px; height: 12px;
|
||||
border: 2px solid #dee2e6;
|
||||
border-top-color: #007bff;
|
||||
border-radius: 50%;
|
||||
animation: sp .7s linear infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@keyframes sp { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Steps list ──────────────────────────────────────────────────────── */
|
||||
.steps-list { list-style: none; margin: 0; padding: 0; }
|
||||
.steps-list li {
|
||||
display: flex;
|
||||
gap: .875rem;
|
||||
align-items: flex-start;
|
||||
padding: .5rem 0;
|
||||
border-bottom: 1px solid #f1f3f5;
|
||||
font-size: .875rem;
|
||||
color: #495057;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.steps-list li:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.step-badge {
|
||||
min-width: 22px; height: 22px;
|
||||
background: #007bff;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: .7rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
margin-top: .15rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Main Header -->
|
||||
<nav class="main-header">
|
||||
<div class="brand-logo"><img src="icon128.png" alt=""></div>
|
||||
<span class="brand-text" id="optTitle">OpenNIT Vault</span>
|
||||
<div class="header-user" id="headerStatus" style="display:none;">
|
||||
<span class="user-badge">
|
||||
<span class="user-dot"></span>
|
||||
<span id="headerUser"></span>
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="content-wrapper">
|
||||
|
||||
<!-- Connection card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Server-Verbindung</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="serverUrl">Server-URL</label>
|
||||
<input type="url" class="form-control" id="serverUrl"
|
||||
placeholder="https://ihre-opennit.firma.de"
|
||||
value="">
|
||||
<span class="form-text">Adresse Ihrer OpenNIT-Instanz – ohne abschließenden Slash.</span>
|
||||
</div>
|
||||
|
||||
<!-- SSO-Anmeldung (empfohlen) -->
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" id="btnSso">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:2px;"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
|
||||
Mit OpenNIT anmelden
|
||||
</button>
|
||||
<button class="btn btn-default" id="btnLogout" style="display:none;">Abmelden</button>
|
||||
<span id="ssoMsg"></span>
|
||||
</div>
|
||||
<span class="form-text">Empfohlen: Anmeldung wie an OpenNIT (lokal + 2FA / Microsoft 365 / Keycloak). Der Zugang wird automatisch erneuert.</span>
|
||||
<div id="statusMsg"></div>
|
||||
|
||||
<!-- Erweitert: manueller Token -->
|
||||
<details style="margin-top:1rem;">
|
||||
<summary style="cursor:pointer;font-size:.82rem;color:#6c757d;font-weight:600;">Erweitert: manueller Token</summary>
|
||||
<div style="margin-top:.75rem;">
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="apiToken">API-Token</label>
|
||||
<input type="password" class="form-control" id="apiToken"
|
||||
placeholder="Token aus dem Vault einfügen…">
|
||||
<span class="form-text">Alternative ohne SSO (z. B. Kiosk/Headless): Vault → „Extension“ → Token generieren.</span>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-default btn-sm" id="btnSave">Token speichern</button>
|
||||
<button class="btn btn-default btn-sm" id="btnTest">Verbindung testen</button>
|
||||
<span id="savedMsg"></span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Sicherheit</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="lockDuration">PIN-Sperre: erneut fragen</label>
|
||||
<select class="form-control" id="lockDuration">
|
||||
<option value="5">Nach 5 Minuten</option>
|
||||
<option value="15">Nach 15 Minuten</option>
|
||||
<option value="60">Nach 1 Stunde</option>
|
||||
<option value="session">Bis der Browser geschlossen wird</option>
|
||||
</select>
|
||||
<span class="form-text">Ist im Web-Tresor ein <strong>Tresor-PIN</strong> aktiv, verlangt die Erweiterung diesen PIN – <strong>serverseitig erzwungen</strong>, damit ein gestohlener Token allein nichts nützt. Diese Einstellung legt nur fest, <em>wie lange</em> eine Entsperrung gilt. Ohne gesetzten Tresor-PIN entfällt die Sperre.</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label"><input type="checkbox" id="clipClear" checked> Zwischenablage nach 30 Sekunden leeren</label>
|
||||
<span class="form-text">Kopierte Passwörter und 2FA-Codes werden nach kurzer Zeit automatisch aus der Zwischenablage entfernt.</span>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary btn-sm" id="btnSaveSec">Speichern</button>
|
||||
<span id="savedSecMsg"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Setup card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Einrichtungsschritte</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="steps-list">
|
||||
<li><span class="step-badge">1</span><span>Öffnen Sie den <strong>Passwort-Vault</strong> in Ihrer OpenNIT-Anwendung.</span></li>
|
||||
<li><span class="step-badge">2</span><span>Klicken Sie auf <strong>„Extension“</strong> in der Toolbar und generieren Sie einen API-Token.</span></li>
|
||||
<li><span class="step-badge">3</span><span>Fügen Sie den Token oben in das Token-Feld ein und klicken Sie auf <strong>Speichern</strong>.</span></li>
|
||||
<li><span class="step-badge">4</span><span>Fokussieren Sie ein Login-Feld auf einer Website – Vault-Vorschläge erscheinen automatisch.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.content-wrapper -->
|
||||
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
227
extension/options.js
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
'use strict';
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
// Gespeicherte Werte laden (überschreibt das vorausgefüllte Feld nur wenn bereits gespeichert)
|
||||
chrome.storage.local.get(['serverUrl', 'apiToken'], cfg => {
|
||||
if (cfg.serverUrl) $('serverUrl').value = cfg.serverUrl;
|
||||
if (cfg.apiToken) $('apiToken').value = '••••••••';
|
||||
});
|
||||
|
||||
// Sicherheits-Einstellungen laden
|
||||
chrome.storage.local.get(['lockDuration', 'clipClear'], cfg => {
|
||||
$('lockDuration').value = (cfg.lockDuration && cfg.lockDuration !== 'off') ? cfg.lockDuration : '15';
|
||||
$('clipClear').checked = cfg.clipClear !== false; // Standard: an
|
||||
});
|
||||
|
||||
$('btnSaveSec').addEventListener('click', () => {
|
||||
chrome.storage.local.set({
|
||||
lockDuration: $('lockDuration').value,
|
||||
clipClear: $('clipClear').checked,
|
||||
}, () => {
|
||||
chrome.runtime.sendMessage({ type: 'LOCK_NOW' });
|
||||
$('savedSecMsg').innerHTML = '<span class="save-ok">✓ Gespeichert</span>';
|
||||
setTimeout(() => { $('savedSecMsg').textContent = ''; }, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
// App-Name und Verbindungsstatus laden (falls Token bereits gesetzt)
|
||||
chrome.storage.local.get(['serverUrl', 'apiToken'], async cfg => {
|
||||
if (!cfg.serverUrl || !cfg.apiToken) return;
|
||||
try {
|
||||
const res = await fetch(`${cfg.serverUrl}/api/vault/extension/status`, {
|
||||
headers: { 'Authorization': `Bearer ${cfg.apiToken}` }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
// Name der Erweiterung bleibt fest „OpenNIT Vault"; die Instanz wird
|
||||
// beim angemeldeten Nutzer zur Orientierung angezeigt.
|
||||
if (data.user) {
|
||||
$('headerUser').textContent = data.app_name ? (data.user + ' · ' + data.app_name) : data.user;
|
||||
$('headerStatus').style.display = '';
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
// HTTPS erzwingen (außer localhost) – sonst gingen Token und Passwörter im
|
||||
// Klartext über die Leitung.
|
||||
function isSecureServerUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.protocol === 'https:') return true;
|
||||
if (u.protocol === 'http:' && /^(localhost|127\.0\.0\.1|\[::1\])$/.test(u.hostname)) return true;
|
||||
return false;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
$('btnSave').addEventListener('click', () => {
|
||||
const url = $('serverUrl').value.trim().replace(/\/$/, '');
|
||||
const token = $('apiToken').value.trim();
|
||||
if (!url) { showStatus('Server-URL darf nicht leer sein.', false); return; }
|
||||
if (!isSecureServerUrl(url)) {
|
||||
showStatus('Bitte eine <strong>https://</strong>-Adresse verwenden (nur localhost darf http:// sein). Sonst würden Token und Passwörter unverschlüsselt übertragen.', false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = { serverUrl: url };
|
||||
if (token && !token.startsWith('•')) data.apiToken = token;
|
||||
|
||||
chrome.storage.local.set(data, () => {
|
||||
chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' });
|
||||
$('savedMsg').innerHTML = '<span class="save-ok">✓ Gespeichert</span>';
|
||||
setTimeout(() => { $('savedMsg').textContent = ''; }, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
$('btnTest').addEventListener('click', async () => {
|
||||
const url = $('serverUrl').value.trim().replace(/\/$/, '');
|
||||
const token = $('apiToken').value.trim();
|
||||
|
||||
if (!url || !token || token.startsWith('•')) {
|
||||
showStatus('Bitte zuerst URL und Token eingeben und speichern.', false);
|
||||
return;
|
||||
}
|
||||
|
||||
$('btnTest').innerHTML = '<span class="fa-spin-sm"></span> Teste…';
|
||||
$('btnTest').disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${url}/api/vault/extension/status`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
showStatus(`Verbunden als <strong>${esc(data.user)}</strong>`, true);
|
||||
if (data.user) {
|
||||
$('headerUser').textContent = data.user;
|
||||
$('headerStatus').style.display = '';
|
||||
}
|
||||
} else {
|
||||
showStatus('Ungültiger Token oder Server-Fehler.', false);
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus('Server nicht erreichbar: ' + esc(e.message), false);
|
||||
}
|
||||
|
||||
$('btnTest').innerHTML = 'Verbindung testen';
|
||||
$('btnTest').disabled = false;
|
||||
});
|
||||
|
||||
// ── SSO-Anmeldung (OAuth 2.0 + PKCE via chrome.identity) ────────────────────
|
||||
function b64url(bytes) {
|
||||
let s = btoa(String.fromCharCode.apply(null, new Uint8Array(bytes)));
|
||||
return s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
function randB64(len) { const a = new Uint8Array(len); crypto.getRandomValues(a); return b64url(a); }
|
||||
async function pkceChallenge(verifier) {
|
||||
const d = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
||||
return b64url(d);
|
||||
}
|
||||
function ssoMsg(msg, ok) {
|
||||
const el = $('ssoMsg');
|
||||
if (ok === null) { el.innerHTML = msg ? ('<span style="color:#6c757d;font-size:.82rem;">' + esc(msg) + '</span>') : ''; return; }
|
||||
el.innerHTML = ok ? ('<span class="save-ok">✓ ' + esc(msg) + '</span>')
|
||||
: ('<span style="color:#dc3545;font-size:.82rem;">' + esc(msg) + '</span>');
|
||||
if (ok) setTimeout(() => { el.innerHTML = ''; }, 3000);
|
||||
}
|
||||
function ssoSet(area, obj) { return new Promise(r => chrome.storage[area].set(obj, r)); }
|
||||
function ssoRemove(area, keys) { return new Promise(r => chrome.storage[area].remove(keys, r)); }
|
||||
|
||||
async function loginWithSso() {
|
||||
const url = $('serverUrl').value.trim().replace(/\/$/, '');
|
||||
if (!url) { ssoMsg('Bitte zuerst die Server-URL eingeben.', false); return; }
|
||||
if (!isSecureServerUrl(url)) { ssoMsg('Bitte eine https://-Adresse verwenden.', false); return; }
|
||||
if (!chrome.identity || !chrome.identity.launchWebAuthFlow) { ssoMsg('Anmeldung wird von diesem Browser nicht unterstützt.', false); return; }
|
||||
|
||||
const verifier = randB64(48);
|
||||
const challenge = await pkceChallenge(verifier);
|
||||
const state = randB64(16);
|
||||
const redirectUri = chrome.identity.getRedirectURL();
|
||||
const authUrl = url + '/vault/extension/authorize?' + new URLSearchParams({
|
||||
client_id: 'opennit-vault-extension', redirect_uri: redirectUri, response_type: 'code',
|
||||
code_challenge: challenge, code_challenge_method: 'S256', state: state, scope: 'vault',
|
||||
}).toString();
|
||||
|
||||
$('btnSso').disabled = true;
|
||||
ssoMsg('Anmeldung läuft…', null);
|
||||
console.log('[OpenNIT Vault] Auth-URL:', authUrl, '| redirect_uri:', redirectUri);
|
||||
chrome.identity.launchWebAuthFlow({ url: authUrl, interactive: true }, async (redirect) => {
|
||||
$('btnSso').disabled = false;
|
||||
const le = chrome.runtime.lastError ? (chrome.runtime.lastError.message || 'unbekannt') : null;
|
||||
console.log('[OpenNIT Vault] launchWebAuthFlow zurück:', { lastError: le, redirect: redirect || null });
|
||||
if (le || !redirect) {
|
||||
ssoMsg('Anmeldung abgebrochen' + (le ? ' – ' + le : ' (keine Rückmeldung)') + '.', false);
|
||||
return;
|
||||
}
|
||||
let params;
|
||||
try { params = new URL(redirect).searchParams; } catch { ssoMsg('Ungültige Antwort.', false); return; }
|
||||
if (params.get('error')) { ssoMsg('Abgelehnt (' + params.get('error') + ').', false); return; }
|
||||
if (params.get('state') !== state) { ssoMsg('Sicherheitsprüfung fehlgeschlagen (state).', false); return; }
|
||||
const code = params.get('code');
|
||||
if (!code) { ssoMsg('Kein Autorisierungscode erhalten.', false); return; }
|
||||
try {
|
||||
const body = new URLSearchParams({ grant_type: 'authorization_code', code: code, code_verifier: verifier, redirect_uri: redirectUri });
|
||||
const res = await fetch(url + '/api/vault/extension/oauth/token', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.access_token) { ssoMsg('Token konnte nicht ausgestellt werden.', false); return; }
|
||||
await ssoSet('local', { serverUrl: url, apiRefreshToken: data.refresh_token, apiRefreshExpiresAt: Date.now() + (data.refresh_expires_in || 0) * 1000 });
|
||||
await ssoRemove('local', ['apiToken']);
|
||||
await ssoSet('session', { accessToken: data.access_token, accessExpiresAt: Date.now() + (data.expires_in || 0) * 1000 });
|
||||
chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' });
|
||||
ssoMsg('Angemeldet.', true);
|
||||
reflectAuthState();
|
||||
loadConnStatus();
|
||||
} catch (e) { ssoMsg('Verbindungsfehler: ' + e.message, false); }
|
||||
});
|
||||
}
|
||||
|
||||
async function logoutSso() {
|
||||
const url = $('serverUrl').value.trim().replace(/\/$/, '');
|
||||
const cfg = await new Promise(r => chrome.storage.local.get(['apiRefreshToken'], r));
|
||||
if (url && cfg.apiRefreshToken) {
|
||||
try {
|
||||
const b = new URLSearchParams({ token: cfg.apiRefreshToken });
|
||||
await fetch(url + '/api/vault/extension/oauth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() });
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
await ssoRemove('local', ['apiRefreshToken', 'apiRefreshExpiresAt']);
|
||||
await ssoRemove('session', ['accessToken', 'accessExpiresAt', 'unlock']);
|
||||
chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' });
|
||||
reflectAuthState();
|
||||
ssoMsg('Abgemeldet.', true);
|
||||
$('headerStatus').style.display = 'none';
|
||||
}
|
||||
|
||||
function reflectAuthState() {
|
||||
chrome.storage.local.get(['apiRefreshToken'], cfg => {
|
||||
const sso = !!cfg.apiRefreshToken;
|
||||
$('btnLogout').style.display = sso ? '' : 'none';
|
||||
$('btnSso').lastChild.textContent = sso ? ' Neu anmelden' : ' Mit OpenNIT anmelden';
|
||||
});
|
||||
}
|
||||
|
||||
// Verbindungsstatus über den Background (nutzt SSO-Access-Token oder manuellen Token)
|
||||
function loadConnStatus() {
|
||||
chrome.runtime.sendMessage({ type: 'CHECK_STATUS' }, data => {
|
||||
if (data && data.ok) {
|
||||
if (data.app_name) { $('optTitle').textContent = 'OpenNIT Vault'; }
|
||||
if (data.user) { $('headerUser').textContent = data.app_name ? (data.user + ' · ' + data.app_name) : data.user; $('headerStatus').style.display = ''; }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$('btnSso').addEventListener('click', loginWithSso);
|
||||
$('btnLogout').addEventListener('click', logoutSso);
|
||||
reflectAuthState();
|
||||
loadConnStatus();
|
||||
|
||||
function showStatus(msg, ok) {
|
||||
const el = $('statusMsg');
|
||||
el.innerHTML = `<div class="alert ${ok ? 'alert-success' : 'alert-danger'}">${msg}</div>`;
|
||||
setTimeout(() => { el.innerHTML = ''; }, 5000);
|
||||
}
|
||||
|
||||
function esc(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
347
extension/popup.html
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg:#f6f7fb; --card:#ffffff; --ink:#1f2330; --muted:#79839a;
|
||||
--line:#edeff4; --brand:#4f46e5; --brand2:#3c8dbc; --ok:#16a34a; --danger:#dc2626;
|
||||
--radius:12px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg:#15171f; --card:#1e212b; --ink:#e7e9f0; --muted:#9aa2b5;
|
||||
--line:#2b2f3b; --brand:#6366f1; --brand2:#3c8dbc; --ok:#22c55e; --danger:#f87171;
|
||||
}
|
||||
.entry-icon, .detail-icon { background:#262a36; }
|
||||
.ico-btn, .field-btn, .ne-icon-btn { background:#262a36; color:#c4c9d6; }
|
||||
.search-input, .panel-input, .lock-input { background:#262a36; color:var(--ink); }
|
||||
.totp-display, .totp-code { color:#fff; }
|
||||
.team-badge { background:#312e54; color:#c7c2f5; }
|
||||
}
|
||||
body {
|
||||
width: 360px;
|
||||
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||||
font-size: 13px; color: var(--ink); background: var(--bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────── */
|
||||
.hd {
|
||||
background: linear-gradient(135deg,#4f46e5 0%,#5b6ee8 45%,#3c8dbc 100%);
|
||||
color: #fff; padding: 13px 14px; display: flex; align-items: center; gap: 11px;
|
||||
}
|
||||
.hd-icon { width: 30px; height: 30px; border-radius: 9px; background: rgba(255,255,255,.18);
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0; overflow: hidden; }
|
||||
.hd-icon img { width: 22px; height: 22px; object-fit: contain; }
|
||||
.hd-main { flex: 1; min-width: 0; }
|
||||
.hd-title { font-weight: 700; font-size: 13.5px; letter-spacing: .01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.hd-user { font-size: 10.5px; color: rgba(255,255,255,.78); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
|
||||
.hd-dot { width: 8px; height: 8px; border-radius: 50%; background: #34d399; box-shadow: 0 0 0 3px rgba(52,211,153,.25); flex-shrink: 0; }
|
||||
|
||||
/* ── Search ─────────────────────────────────────────────── */
|
||||
.search-wrap { padding: 10px 12px 8px; background: var(--bg); }
|
||||
.search-input {
|
||||
width: 100%; padding: 9px 12px 9px 34px;
|
||||
border: 1.5px solid var(--line); border-radius: 10px; font-size: 12.5px; outline: none;
|
||||
background: var(--card) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='13' height='13' viewBox='0 0 24 24' fill='none' stroke='%2379839a' stroke-width='2.2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.35-4.35'/%3E%3C/svg%3E") 12px center/13px no-repeat;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.search-input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(79,70,229,.14); }
|
||||
|
||||
/* ── Section labels ─────────────────────────────────────── */
|
||||
.section-lbl {
|
||||
padding: 6px 14px 4px; font-size: 10px; font-weight: 700; color: var(--muted);
|
||||
text-transform: uppercase; letter-spacing: .07em;
|
||||
}
|
||||
.section-lbl.match { color: var(--brand); }
|
||||
|
||||
/* ── Entries ────────────────────────────────────────────── */
|
||||
.entries { padding: 0 8px 4px; overflow-y: auto; }
|
||||
.entries.scrollable { max-height: 256px; }
|
||||
.entry {
|
||||
padding: 9px 10px; margin: 3px 0; border-radius: 10px;
|
||||
display: flex; align-items: center; gap: 11px; cursor: pointer; background: var(--card);
|
||||
border: 1.5px solid transparent; transition: border-color .12s, box-shadow .12s, transform .04s;
|
||||
}
|
||||
.entry:hover { border-color: #e3e6ef; box-shadow: 0 4px 14px rgba(31,35,48,.07); }
|
||||
.entry:active { transform: scale(.995); }
|
||||
.entry.kbd-sel { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(79,70,229,.16); }
|
||||
.entry-2fa { font-size: 8.5px; font-weight: 700; color: #0369a1; background: #e0f2fe; border-radius: 4px; padding: 1px 4px; margin-left: 6px; vertical-align: middle; letter-spacing: .03em; }
|
||||
.entry-chev { color: #c2c8d6; flex-shrink: 0; }
|
||||
.entry:hover .entry-chev, .entry.kbd-sel .entry-chev { color: var(--brand); }
|
||||
.entry-icon {
|
||||
width: 32px; height: 32px; border-radius: 9px; background: #eef0f7;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 13px; font-weight: 700; flex-shrink: 0; overflow: hidden;
|
||||
}
|
||||
.entry-icon img { width: 20px; height: 20px; object-fit: contain; }
|
||||
.entry-info { flex: 1; min-width: 0; }
|
||||
.entry-title { font-weight: 600; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 12.5px; }
|
||||
.entry-user { color: var(--muted); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
|
||||
.entry-meta { display: flex; gap: 5px; align-items: center; margin-top: 2px; }
|
||||
.entry-url { color: #aab2c3; font-size: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; }
|
||||
.entry-actions { display: flex; gap: 4px; opacity: 0; transition: opacity .12s; flex-shrink: 0; }
|
||||
.entry:hover .entry-actions { opacity: 1; }
|
||||
.ico-btn { background: #f3f4f8; border: none; border-radius: 8px; width: 26px; height: 26px; cursor: pointer; font-size: 11px; color: #5b6478; display: inline-flex; align-items: center; justify-content: center; transition: background .12s, color .12s; }
|
||||
.ico-btn:hover { background: var(--brand); color: #fff; }
|
||||
.team-badge { font-size: 9px; background: #ede9fe; color: #6d28d9; border-radius: 5px; padding: 1.5px 6px; white-space: nowrap; flex-shrink: 0; font-weight: 700; }
|
||||
.totp-btn { background: #e0f2fe; color: #0369a1; font-weight: 700; width: auto; padding: 0 8px; }
|
||||
.totp-btn:hover { background: var(--brand2); color: #fff; }
|
||||
.totp-row { display: none; padding: 6px 12px 8px 52px; }
|
||||
.totp-display { display: flex; align-items: center; gap: 9px; background: #0f172a; border-radius: 10px; padding: 8px 11px; }
|
||||
.totp-code { font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: 16px; font-weight: 700; letter-spacing: .14em; color: #fff; }
|
||||
.totp-bar-wrap { flex: 1; height: 4px; background: rgba(255,255,255,.18); border-radius: 3px; overflow: hidden; }
|
||||
.totp-bar-fill { height: 100%; background: #34d399; border-radius: 3px; transition: width 1s linear; }
|
||||
.totp-secs { font-size: 10px; color: #94a3b8; white-space: nowrap; min-width: 22px; text-align: right; }
|
||||
.totp-hint { font-size: 10px; color: var(--ok); margin-top: 4px; }
|
||||
|
||||
/* ── States ─────────────────────────────────────────────── */
|
||||
.empty { padding: 30px 22px; text-align: center; color: var(--muted); line-height: 1.7; font-size: 12px; }
|
||||
.empty-emoji { font-size: 30px; display: block; margin-bottom: 8px; opacity: .7; }
|
||||
.error { padding: 14px; text-align: center; color: var(--danger); font-size: 11px; line-height: 1.6; }
|
||||
|
||||
/* ── New Entry Panel ────────────────────────────────────── */
|
||||
#newEntryPanel { display: none; background: var(--card); }
|
||||
.panel-hd { background: linear-gradient(135deg,#4f46e5,#3c8dbc); color: #fff; padding: 11px 14px; display: flex; align-items: center; }
|
||||
.panel-hd-title { flex: 1; font-size: 12px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.panel-close { background: none; border: none; color: rgba(255,255,255,.8); font-size: 17px; cursor: pointer; padding: 0 2px; line-height: 1; }
|
||||
.panel-close:hover { color: #fff; }
|
||||
.panel-body { padding: 14px; display: flex; flex-direction: column; gap: 9px; }
|
||||
.panel-input { width: 100%; padding: 9px 11px; border: 1.5px solid var(--line); border-radius: 9px; font-size: 12.5px; outline: none; font-family: inherit; transition: border-color .15s, box-shadow .15s; }
|
||||
.panel-input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(79,70,229,.14); }
|
||||
.panel-save { background: var(--brand); color: #fff; border: none; border-radius: 9px; padding: 10px 14px; font-size: 12.5px; font-weight: 700; cursor: pointer; width: 100%; transition: background .12s; }
|
||||
.panel-save:hover { background: #4338ca; }
|
||||
.panel-save:disabled { background: #aeb4c4; cursor: default; }
|
||||
.panel-msg { font-size: 11px; color: var(--danger); min-height: 14px; }
|
||||
|
||||
/* ── Detail Panel ───────────────────────────────────────── */
|
||||
#detailPanel { display: none; background: var(--bg); }
|
||||
.panel-back { background: none; border: none; color: rgba(255,255,255,.85); cursor: pointer; padding: 0 6px 0 0; display: flex; align-items: center; line-height: 1; }
|
||||
.panel-back:hover { color: #fff; }
|
||||
.detail-body { padding: 14px; }
|
||||
.detail-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
|
||||
.detail-icon { width: 44px; height: 44px; border-radius: 12px; background: #eef0f7; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 700; flex-shrink: 0; overflow: hidden; }
|
||||
.detail-icon img { width: 28px; height: 28px; object-fit: contain; }
|
||||
.detail-headinfo { min-width: 0; flex: 1; }
|
||||
.detail-name { font-weight: 700; font-size: 15px; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.detail-url-link { color: var(--brand); font-size: 11px; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
|
||||
.detail-url-link:hover { text-decoration: underline; }
|
||||
.field { background: var(--card); border: 1.5px solid var(--line); border-radius: 11px; padding: 8px 11px; margin-bottom: 9px; }
|
||||
.field-lbl { font-size: 9.5px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; margin-bottom: 3px; }
|
||||
.field-row { display: flex; align-items: center; gap: 6px; }
|
||||
.field-val { flex: 1; min-width: 0; font-size: 13px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.field-val.mono { font-family: ui-monospace,SFMono-Regular,Menlo,monospace; letter-spacing: .02em; }
|
||||
.field-val.empty { color: #aab2c3; font-style: italic; }
|
||||
.field-btn { background: #f3f4f8; border: none; border-radius: 8px; width: 30px; height: 30px; flex-shrink: 0; cursor: pointer; color: #5b6478; display: inline-flex; align-items: center; justify-content: center; transition: background .12s, color .12s; }
|
||||
.field-btn:hover { background: var(--brand); color: #fff; }
|
||||
.field-totp-code { font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: 16px; font-weight: 700; letter-spacing: .12em; color: var(--ink); }
|
||||
.field-totp-secs { font-size: 10px; color: var(--muted); min-width: 24px; text-align: right; }
|
||||
.detail-fill { margin-top: 4px; width: 100%; padding: 11px; background: var(--brand); color: #fff; border: none; border-radius: 11px; font-size: 13px; font-weight: 700; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 7px; transition: background .12s; }
|
||||
.detail-fill:hover { background: #4338ca; }
|
||||
.detail-fill:disabled { background: #aeb4c4; cursor: default; }
|
||||
|
||||
/* ── New-Entry: Passwort-Zeile + Generator ──────────────── */
|
||||
.ne-pass-row { display: flex; gap: 6px; align-items: center; }
|
||||
.ne-pass-row .panel-input { flex: 1; }
|
||||
.ne-icon-btn { flex-shrink: 0; width: 36px; height: 36px; border: 1.5px solid var(--line); background: var(--card); border-radius: 9px; cursor: pointer; color: #5b6478; display: inline-flex; align-items: center; justify-content: center; transition: background .12s, color .12s, border-color .12s; }
|
||||
.ne-icon-btn:hover { background: var(--brand); border-color: var(--brand); color: #fff; }
|
||||
.field-notes { font-size: 12px; color: var(--ink); white-space: pre-wrap; word-break: break-word; max-height: 90px; overflow-y: auto; margin-top: 2px; }
|
||||
|
||||
/* ── Lock Screen ────────────────────────────────────────── */
|
||||
#lockScreen { display: none; background: var(--bg); }
|
||||
.lock-body { padding: 34px 24px 30px; display: flex; flex-direction: column; align-items: center; text-align: center; }
|
||||
.lock-icon { width: 64px; height: 64px; border-radius: 18px; background: linear-gradient(135deg,#4f46e5,#3c8dbc); color: #fff; display: flex; align-items: center; justify-content: center; margin-bottom: 14px; box-shadow: 0 8px 22px rgba(79,70,229,.3); }
|
||||
.lock-title { font-size: 16px; font-weight: 700; color: var(--ink); }
|
||||
.lock-sub { font-size: 12px; color: var(--muted); margin: 4px 0 18px; }
|
||||
.lock-input { width: 100%; padding: 11px 13px; border: 1.5px solid var(--line); border-radius: 10px; font-size: 16px; text-align: center; letter-spacing: .25em; outline: none; background: var(--card); color: var(--ink); transition: border-color .15s, box-shadow .15s; }
|
||||
.lock-input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(79,70,229,.14); }
|
||||
.lock-btn { width: 100%; margin-top: 11px; padding: 11px; background: var(--brand); color: #fff; border: none; border-radius: 10px; font-size: 13px; font-weight: 700; cursor: pointer; transition: background .12s; }
|
||||
.lock-btn:hover { background: #4338ca; }
|
||||
.lock-btn:disabled { background: #aeb4c4; cursor: default; }
|
||||
.lock-msg { font-size: 11.5px; color: var(--danger); min-height: 15px; margin-top: 9px; }
|
||||
|
||||
/* ── Footer ─────────────────────────────────────────────── */
|
||||
.ft { padding: 9px 12px; border-top: 1px solid var(--line); background: var(--card); display: flex; gap: 7px; }
|
||||
.ft-btn {
|
||||
flex: 1; padding: 9px 0; background: var(--bg); border: 1.5px solid var(--line); border-radius: 10px;
|
||||
cursor: pointer; color: #5b6478; text-decoration: none;
|
||||
display: flex; align-items: center; justify-content: center; transition: all .12s;
|
||||
}
|
||||
.ft-btn:hover { background: var(--brand); border-color: var(--brand); color: #fff; transform: translateY(-1px); }
|
||||
.ft-btn svg { display: block; }
|
||||
|
||||
/* ── Toast ──────────────────────────────────────────────── */
|
||||
.toast { position: fixed; bottom: 14px; left: 50%; transform: translateX(-50%); background: #0f172a; color: #fff; padding: 8px 16px; border-radius: 9px; font-size: 11.5px; opacity: 0; transition: opacity .2s; pointer-events: none; white-space: nowrap; box-shadow: 0 6px 20px rgba(0,0,0,.25); }
|
||||
.toast.show { opacity: 1; }
|
||||
|
||||
/* ── Spinner ────────────────────────────────────────────── */
|
||||
.spin { display: inline-block; width: 18px; height: 18px; border: 2.5px solid var(--line); border-top-color: var(--brand); border-radius: 50%; animation: sp .7s linear infinite; margin: auto; }
|
||||
@keyframes sp { to { transform: rotate(360deg); } }
|
||||
.loading { padding: 30px; display: flex; justify-content: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
<div class="hd">
|
||||
<div class="hd-icon"><img src="icon32.png" alt=""></div>
|
||||
<div class="hd-main">
|
||||
<div class="hd-title" id="hdTitle">OpenNIT Vault</div>
|
||||
<div class="hd-user" id="hdUser"></div>
|
||||
</div>
|
||||
<div class="hd-dot" title="Verbunden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="search-wrap">
|
||||
<input class="search-input" id="search" type="text" placeholder="Einträge suchen…" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<!-- Entry list (JS-controlled) -->
|
||||
<div id="listWrap"><div class="loading"><div class="spin"></div></div></div>
|
||||
|
||||
<!-- New Entry Panel (initially hidden) -->
|
||||
<div id="newEntryPanel">
|
||||
<div class="panel-hd">
|
||||
<span class="panel-hd-title">Neuen Eintrag anlegen</span>
|
||||
<button class="panel-close" id="btnCloseNew" title="Schließen">✕</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<input class="panel-input" id="neTitle" type="text" placeholder="Titel *" autocomplete="off">
|
||||
<input class="panel-input" id="neUsername" type="text" placeholder="Benutzername" autocomplete="off">
|
||||
<div class="ne-pass-row">
|
||||
<input class="panel-input" id="nePassword" type="password" placeholder="Passwort" autocomplete="new-password">
|
||||
<button class="ne-icon-btn" id="btnRevealNewPw" type="button" title="Anzeigen/Verbergen">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
<button class="ne-icon-btn" id="btnGenPw" type="button" title="Sicheres Passwort generieren">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<input class="panel-input" id="neUrl" type="url" placeholder="URL (https://…)" autocomplete="off">
|
||||
<textarea class="panel-input" id="neNotes" placeholder="Notizen" rows="2" style="resize:vertical;font-family:inherit;"></textarea>
|
||||
<button class="panel-save" id="btnSaveNew">Speichern</button>
|
||||
<div class="panel-msg" id="newEntryMsg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Panel (initially hidden) -->
|
||||
<div id="detailPanel">
|
||||
<div class="panel-hd">
|
||||
<button class="panel-back" id="btnDetailBack" title="Zurück">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
</button>
|
||||
<span class="panel-hd-title" id="detailHdTitle">Eintrag</span>
|
||||
<button class="panel-close" id="btnDetailClose" title="Schließen">✕</button>
|
||||
</div>
|
||||
<div class="detail-body">
|
||||
<div class="detail-head">
|
||||
<div class="detail-icon" id="detailIcon"></div>
|
||||
<div class="detail-headinfo">
|
||||
<div class="detail-name" id="detailName"></div>
|
||||
<a class="detail-url-link" id="detailUrlLink" target="_blank" rel="noopener"></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" id="fieldUser">
|
||||
<div class="field-lbl">Benutzername</div>
|
||||
<div class="field-row">
|
||||
<span class="field-val" id="detailUser"></span>
|
||||
<button class="field-btn" id="btnRevealUser" title="Anzeigen/Verbergen">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
<button class="field-btn" id="btnCopyUser" title="Kopieren">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" id="fieldPass">
|
||||
<div class="field-lbl">Passwort</div>
|
||||
<div class="field-row">
|
||||
<span class="field-val mono" id="detailPass">••••••••••</span>
|
||||
<button class="field-btn" id="btnRevealPass" title="Anzeigen/Verbergen">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
<button class="field-btn" id="btnCopyPass" title="Kopieren">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" id="fieldTotp" style="display:none;">
|
||||
<div class="field-lbl">2FA-Code (TOTP)</div>
|
||||
<div class="field-row">
|
||||
<span class="field-totp-code" id="detailTotp">—</span>
|
||||
<span class="totp-bar-wrap" style="max-width:90px;"><span class="totp-bar-fill" id="detailTotpBar"></span></span>
|
||||
<span class="field-totp-secs" id="detailTotpSecs"></span>
|
||||
<button class="field-btn" id="btnCopyTotp" title="Kopieren">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" id="fieldNotes" style="display:none;">
|
||||
<div class="field-row" style="align-items:flex-start;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="field-lbl">Notizen</div>
|
||||
<div class="field-notes" id="detailNotes"></div>
|
||||
</div>
|
||||
<button class="field-btn" id="btnCopyNotes" title="Kopieren">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="detail-fill" id="btnDetailFill">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
||||
Auf dieser Seite ausfüllen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lock Screen (initially hidden) -->
|
||||
<div id="lockScreen">
|
||||
<div class="lock-body">
|
||||
<div class="lock-icon">
|
||||
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
</div>
|
||||
<div class="lock-title">Tresor gesperrt</div>
|
||||
<div class="lock-sub">Mit deinem Tresor-PIN entsperren</div>
|
||||
<input class="lock-input" id="lockPin" type="password" inputmode="numeric" placeholder="PIN" autocomplete="off">
|
||||
<button class="lock-btn" id="lockSubmit">Entsperren</button>
|
||||
<div class="lock-msg" id="lockMsg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="ft">
|
||||
<!-- New Entry -->
|
||||
<button class="ft-btn" id="btnNew" title="Neuen Eintrag anlegen">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Lock now (only shown when a PIN lock is active) -->
|
||||
<button class="ft-btn" id="btnLock" title="Jetzt sperren" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
</button>
|
||||
<!-- Settings -->
|
||||
<button class="ft-btn" id="btnOptions" title="Einstellungen">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Open Vault -->
|
||||
<a class="ft-btn" id="btnOpen" target="_blank" title="Vault im Browser öffnen">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
602
extension/popup.js
Normal file
|
|
@ -0,0 +1,602 @@
|
|||
'use strict';
|
||||
|
||||
let allEntries = null;
|
||||
let pageMatches = [];
|
||||
let entryIndex = {}; // id -> entry
|
||||
let detailState = null; // aktiver Eintrag im Detail-Panel
|
||||
let selIndex = -1; // Tastatur-Auswahl in der Liste
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
async function init() {
|
||||
chrome.storage.local.get(['serverUrl'], cfg => {
|
||||
if (cfg.serverUrl) $('btnOpen').href = cfg.serverUrl + '/vault';
|
||||
});
|
||||
|
||||
$('search').addEventListener('input', onSearch);
|
||||
$('search').addEventListener('keydown', onListKeydown);
|
||||
$('btnOptions').addEventListener('click', () => chrome.runtime.openOptionsPage());
|
||||
$('btnNew').addEventListener('click', openNewPanel);
|
||||
$('btnCloseNew').addEventListener('click', closeNewPanel);
|
||||
$('btnSaveNew').addEventListener('click', saveNewEntry);
|
||||
$('btnGenPw').addEventListener('click', generatePassword);
|
||||
$('btnRevealNewPw').addEventListener('click', () => {
|
||||
const f = $('nePassword');
|
||||
f.type = f.type === 'password' ? 'text' : 'password';
|
||||
});
|
||||
|
||||
// Detail-Panel
|
||||
$('btnDetailBack').addEventListener('click', closeDetail);
|
||||
$('btnDetailClose').addEventListener('click', closeDetail);
|
||||
$('btnRevealUser').addEventListener('click', toggleRevealUser);
|
||||
$('btnCopyUser').addEventListener('click', () => copySecret($('detailUser').dataset.value || '', 'Benutzername kopiert'));
|
||||
$('btnRevealPass').addEventListener('click', toggleRevealPass);
|
||||
$('btnCopyPass').addEventListener('click', copyDetailPassword);
|
||||
$('btnCopyTotp').addEventListener('click', () => { const c = $('detailTotp').dataset.code || ''; if (c) copySecret(c, 'TOTP kopiert'); });
|
||||
$('btnCopyNotes').addEventListener('click', () => copyToClipboard($('detailNotes').dataset.value || '', 'Notiz kopiert'));
|
||||
$('btnDetailFill').addEventListener('click', fillActiveTab);
|
||||
|
||||
// Lock-Screen
|
||||
$('lockSubmit').addEventListener('click', submitPin);
|
||||
$('lockPin').addEventListener('keydown', e => { if (e.key === 'Enter') submitPin(); });
|
||||
$('btnLock').addEventListener('click', lockNow);
|
||||
|
||||
boot();
|
||||
}
|
||||
|
||||
// Reihenfolge: Status (App/User/PIN) → Lock prüfen → Liste oder PIN-Schirm.
|
||||
function boot() {
|
||||
chrome.runtime.sendMessage({ type: 'CHECK_STATUS' }, resp => {
|
||||
if (resp?.ok) {
|
||||
$('hdTitle').textContent = 'OpenNIT Vault';
|
||||
// Untertitel: angemeldeter Nutzer und – zur Orientierung – die Instanz.
|
||||
const parts = [];
|
||||
if (resp.user) parts.push(resp.user);
|
||||
if (resp.app_name) parts.push(resp.app_name);
|
||||
$('hdUser').textContent = parts.join(' · ');
|
||||
}
|
||||
chrome.runtime.sendMessage({ type: 'GET_LOCK' }, lock => {
|
||||
$('btnLock').style.display = lock?.required ? '' : 'none';
|
||||
if (lock?.required && !lock.unlocked) {
|
||||
showLockScreen();
|
||||
} else {
|
||||
hideLockScreen();
|
||||
reload(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Lock-Screen ────────────────────────────────────────────────────────────
|
||||
function showLockScreen() {
|
||||
$('lockScreen').style.display = 'block';
|
||||
$('listWrap').style.display = 'none';
|
||||
$('newEntryPanel').style.display = 'none';
|
||||
$('detailPanel').style.display = 'none';
|
||||
$('search').closest('.search-wrap').style.display = 'none';
|
||||
$('lockMsg').textContent = '';
|
||||
$('lockPin').value = '';
|
||||
setTimeout(() => $('lockPin').focus(), 50);
|
||||
}
|
||||
function hideLockScreen() {
|
||||
$('lockScreen').style.display = 'none';
|
||||
$('search').closest('.search-wrap').style.display = '';
|
||||
}
|
||||
function submitPin() {
|
||||
const pin = $('lockPin').value;
|
||||
if (!pin) { $('lockMsg').textContent = 'Bitte PIN eingeben.'; return; }
|
||||
$('lockSubmit').disabled = true;
|
||||
$('lockSubmit').textContent = '…';
|
||||
$('lockMsg').textContent = '';
|
||||
chrome.runtime.sendMessage({ type: 'DO_UNLOCK', pin }, resp => {
|
||||
$('lockSubmit').disabled = false;
|
||||
$('lockSubmit').textContent = 'Entsperren';
|
||||
if (resp?.ok) {
|
||||
hideLockScreen();
|
||||
reload(true);
|
||||
} else {
|
||||
let m = resp?.error || 'PIN falsch.';
|
||||
if (resp?.lockSecs > 0) m += ' (' + resp.lockSecs + 's gesperrt)';
|
||||
$('lockMsg').textContent = m;
|
||||
$('lockPin').value = '';
|
||||
$('lockPin').focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
function lockNow() {
|
||||
chrome.runtime.sendMessage({ type: 'LOCK_NOW' }, () => showLockScreen());
|
||||
}
|
||||
|
||||
function reload(force) {
|
||||
closeDetailTimers();
|
||||
detailState = null;
|
||||
selIndex = -1;
|
||||
$('search').value = '';
|
||||
$('listWrap').innerHTML = '<div class="loading"><div class="spin"></div></div>';
|
||||
$('listWrap').style.display = '';
|
||||
$('newEntryPanel').style.display = 'none';
|
||||
$('detailPanel').style.display = 'none';
|
||||
$('search').closest('.search-wrap').style.display = '';
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'GET_ENTRIES', force }, resp => {
|
||||
// Serverseitig gesperrt (Token-Härtung) → PIN-Schirm zeigen.
|
||||
if (resp?.locked) { showLockScreen(); return; }
|
||||
allEntries = resp?.entries ?? null;
|
||||
entryIndex = {};
|
||||
if (allEntries === null) {
|
||||
$('listWrap').innerHTML = '<div class="error">⚠ Nicht verbunden.<br>Einstellungen prüfen.</div>';
|
||||
return;
|
||||
}
|
||||
indexEntries(allEntries);
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, tabs => {
|
||||
const url = tabs[0]?.url;
|
||||
if (url && !url.startsWith('chrome://') && !url.startsWith('chrome-extension://')) {
|
||||
chrome.runtime.sendMessage({ type: 'GET_MATCHING_ENTRIES', url }, r2 => {
|
||||
pageMatches = r2?.entries || [];
|
||||
indexEntries(pageMatches);
|
||||
renderDefault();
|
||||
});
|
||||
} else {
|
||||
pageMatches = [];
|
||||
renderDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function indexEntries(list) {
|
||||
(list || []).forEach(e => { entryIndex[String(e.id)] = e; });
|
||||
}
|
||||
|
||||
function renderDefault() {
|
||||
selIndex = -1;
|
||||
if (pageMatches.length > 0) {
|
||||
$('listWrap').innerHTML =
|
||||
'<div class="section-lbl match">Passend für diese Seite</div>' +
|
||||
'<div class="entries" id="eList">' + pageMatches.map(e => entryHtml(e)).join('') + '</div>';
|
||||
} else {
|
||||
$('listWrap').innerHTML =
|
||||
'<div class="section-lbl">Alle Einträge (' + allEntries.length + ')</div>' +
|
||||
'<div class="entries scrollable" id="eList">' +
|
||||
(allEntries.length ? allEntries.map(e => entryHtml(e)).join('') : '<div class="empty">Noch keine Einträge vorhanden.</div>') +
|
||||
'</div>';
|
||||
}
|
||||
const el = document.getElementById('eList');
|
||||
if (el) attachHandlers(el);
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
selIndex = -1;
|
||||
const q = ($('search').value || '').trim().toLowerCase();
|
||||
if (!q) { renderDefault(); return; }
|
||||
if (!allEntries) return;
|
||||
|
||||
const filtered = allEntries.filter(e =>
|
||||
(e.title ||'').toLowerCase().includes(q) ||
|
||||
(e.username ||'').toLowerCase().includes(q) ||
|
||||
(e.url ||'').toLowerCase().includes(q) ||
|
||||
(e.notes ||'').toLowerCase().includes(q) ||
|
||||
(e.team_name||'').toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
$('listWrap').innerHTML =
|
||||
'<div class="section-lbl">Suche (' + filtered.length + ')</div>' +
|
||||
'<div class="entries scrollable" id="eList">' +
|
||||
(filtered.length ? filtered.map(e => entryHtml(e)).join('') : '<div class="empty">Keine Einträge gefunden.</div>') +
|
||||
'</div>';
|
||||
|
||||
const el = document.getElementById('eList');
|
||||
if (el) attachHandlers(el);
|
||||
}
|
||||
|
||||
// Tastatur-Navigation aus dem Suchfeld heraus (↑↓ wählt, Enter öffnet).
|
||||
function onListKeydown(e) {
|
||||
if (detailState || $('newEntryPanel').style.display === 'block') return;
|
||||
const items = [...document.querySelectorAll('#eList .entry')];
|
||||
if (!items.length) return;
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSel(items, selIndex + 1); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(items, selIndex - 1); }
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const target = selIndex >= 0 ? items[selIndex] : items[0];
|
||||
if (target) openDetail(target.dataset.id);
|
||||
}
|
||||
}
|
||||
function setSel(items, idx) {
|
||||
items.forEach(i => i.classList.remove('kbd-sel'));
|
||||
selIndex = Math.max(0, Math.min(idx, items.length - 1));
|
||||
const el = items[selIndex];
|
||||
if (el) { el.classList.add('kbd-sel'); el.scrollIntoView({ block: 'nearest' }); }
|
||||
}
|
||||
|
||||
// ── New Entry ─────────────────────────────────────────────────────────────
|
||||
function openNewPanel() {
|
||||
$('listWrap').style.display = 'none';
|
||||
$('search').closest('.search-wrap').style.display = 'none';
|
||||
$('detailPanel').style.display = 'none';
|
||||
$('newEntryPanel').style.display = 'block';
|
||||
$('neTitle').value = '';
|
||||
$('neUsername').value = '';
|
||||
$('nePassword').value = '';
|
||||
$('nePassword').type = 'password';
|
||||
$('neUrl').value = '';
|
||||
$('neNotes').value = '';
|
||||
$('newEntryMsg').textContent = '';
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, tabs => {
|
||||
const url = tabs[0]?.url;
|
||||
if (url && !url.startsWith('chrome://') && !url.startsWith('chrome-extension://')) {
|
||||
$('neUrl').value = url;
|
||||
}
|
||||
});
|
||||
$('neTitle').focus();
|
||||
}
|
||||
|
||||
function closeNewPanel() {
|
||||
$('newEntryPanel').style.display = 'none';
|
||||
$('listWrap').style.display = '';
|
||||
$('search').closest('.search-wrap').style.display = '';
|
||||
}
|
||||
|
||||
function generatePassword() {
|
||||
const len = 20;
|
||||
const sets = [
|
||||
'abcdefghijkmnopqrstuvwxyz',
|
||||
'ABCDEFGHJKLMNPQRSTUVWXYZ',
|
||||
'23456789',
|
||||
'!@#$%^&*()-_=+[]{}',
|
||||
];
|
||||
const all = sets.join('');
|
||||
const buf = new Uint32Array(len);
|
||||
crypto.getRandomValues(buf);
|
||||
let out = [];
|
||||
// Mindestens ein Zeichen je Set
|
||||
sets.forEach((s, i) => { out.push(s[buf[i] % s.length]); });
|
||||
for (let i = sets.length; i < len; i++) out.push(all[buf[i] % all.length]);
|
||||
// mischen
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = buf[i] % (i + 1);
|
||||
[out[i], out[j]] = [out[j], out[i]];
|
||||
}
|
||||
$('nePassword').value = out.join('');
|
||||
$('nePassword').type = 'text';
|
||||
}
|
||||
|
||||
async function saveNewEntry() {
|
||||
const title = $('neTitle').value.trim();
|
||||
if (!title) { $('newEntryMsg').textContent = 'Titel ist erforderlich.'; return; }
|
||||
|
||||
const cfg = await new Promise(r => chrome.storage.local.get(['serverUrl', 'apiToken'], r));
|
||||
if (!cfg.serverUrl || !cfg.apiToken) { $('newEntryMsg').textContent = 'Nicht konfiguriert.'; return; }
|
||||
|
||||
$('btnSaveNew').disabled = true;
|
||||
$('btnSaveNew').textContent = '...';
|
||||
$('newEntryMsg').textContent = '';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('title', title);
|
||||
fd.append('username', $('neUsername').value.trim());
|
||||
fd.append('password', $('nePassword').value);
|
||||
fd.append('url', $('neUrl').value.trim());
|
||||
fd.append('notes', $('neNotes').value.trim());
|
||||
|
||||
try {
|
||||
const res = await fetch(cfg.serverUrl + '/api/vault/extension/entries', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + cfg.apiToken },
|
||||
body: fd,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' });
|
||||
closeNewPanel();
|
||||
reload(true);
|
||||
showToast('Eintrag gespeichert');
|
||||
} else {
|
||||
$('newEntryMsg').textContent = data.error || 'Fehler beim Speichern.';
|
||||
}
|
||||
} catch (e) {
|
||||
$('newEntryMsg').textContent = 'Verbindungsfehler: ' + e.message;
|
||||
}
|
||||
|
||||
$('btnSaveNew').disabled = false;
|
||||
$('btnSaveNew').textContent = 'Speichern';
|
||||
}
|
||||
|
||||
// ── Liste (Klick öffnet Detailansicht) ─────────────────────────────────────
|
||||
function monogram(title) {
|
||||
const s = String(title || '?');
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360;
|
||||
const ch = s.charAt(0).toUpperCase().replace(/[&<>]/g, '');
|
||||
return { hue: h, ch: ch };
|
||||
}
|
||||
|
||||
function entryHtml(e) {
|
||||
const userText = esc(e.username) || '<span style="color:#adb5bd;font-style:italic">Kein Benutzername</span>';
|
||||
const m = monogram(e.title);
|
||||
const icon = `<span class="entry-mono" style="display:inline-flex;width:20px;height:20px;border-radius:4px;align-items:center;justify-content:center;font-size:11px;font-weight:700;background:hsl(${m.hue},52%,90%);color:hsl(${m.hue},55%,38%);">${m.ch}</span>`;
|
||||
const totpBadge = e.has_totp ? '<span class="entry-2fa">2FA</span>' : '';
|
||||
return `
|
||||
<div class="entry" data-id="${e.id}" data-domain="${escAttr(e.favicon_domain)}">
|
||||
<div class="entry-icon">${icon}</div>
|
||||
<div class="entry-info">
|
||||
<div class="entry-title">${esc(e.title)}${totpBadge}</div>
|
||||
<div class="entry-user">${userText}</div>
|
||||
<div class="entry-meta">
|
||||
<div class="entry-url">${esc(e.url) || ''}</div>
|
||||
${e.team_name ? `<span class="team-badge">${esc(e.team_name)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<svg class="entry-chev" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function attachHandlers(container) {
|
||||
container.querySelectorAll('.entry').forEach(row => {
|
||||
row.addEventListener('click', () => openDetail(row.dataset.id));
|
||||
});
|
||||
loadFavicons(container);
|
||||
}
|
||||
|
||||
function loadFavicons(container) {
|
||||
container.querySelectorAll('.entry[data-domain]').forEach(row => {
|
||||
if (!row.dataset.domain) return;
|
||||
const id = row.dataset.id;
|
||||
chrome.runtime.sendMessage({ type: 'GET_FAVICON', id }, resp => {
|
||||
if (resp?.dataUrl) {
|
||||
const ic = row.querySelector('.entry-icon');
|
||||
if (ic) ic.innerHTML = '<img src="' + resp.dataUrl + '" alt="">';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Detailansicht ─────────────────────────────────────────────────────────
|
||||
function closeDetailTimers() {
|
||||
if (detailState && detailState.totpInterval) {
|
||||
clearInterval(detailState.totpInterval);
|
||||
detailState.totpInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(id) {
|
||||
const e = entryIndex[String(id)];
|
||||
if (!e) return;
|
||||
closeDetailTimers();
|
||||
detailState = { id: String(id), password: null, revealUser: true, revealPass: false, totpInterval: null };
|
||||
|
||||
$('listWrap').style.display = 'none';
|
||||
$('search').closest('.search-wrap').style.display = 'none';
|
||||
$('newEntryPanel').style.display = 'none';
|
||||
$('detailPanel').style.display = 'block';
|
||||
|
||||
$('detailHdTitle').textContent = e.title || 'Eintrag';
|
||||
$('detailName').textContent = e.title || '';
|
||||
|
||||
// Icon: Favicon (gecacht) oder Monogramm
|
||||
const icon = $('detailIcon');
|
||||
const m = monogram(e.title);
|
||||
icon.style.background = `hsl(${m.hue},52%,90%)`;
|
||||
icon.innerHTML = `<span style="color:hsl(${m.hue},55%,38%);">${m.ch}</span>`;
|
||||
if (e.favicon_domain) {
|
||||
chrome.runtime.sendMessage({ type: 'GET_FAVICON', id }, resp => {
|
||||
if (resp?.dataUrl && detailState && detailState.id === String(id)) {
|
||||
icon.style.background = '#eef0f7';
|
||||
icon.innerHTML = '<img src="' + resp.dataUrl + '" alt="">';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// URL
|
||||
const urlLink = $('detailUrlLink');
|
||||
const firstUrl = String(e.url || '').split('\n')[0].trim();
|
||||
if (firstUrl) {
|
||||
urlLink.textContent = firstUrl;
|
||||
urlLink.href = /^https?:\/\//i.test(firstUrl) ? firstUrl : 'https://' + firstUrl;
|
||||
urlLink.style.display = '';
|
||||
} else {
|
||||
urlLink.style.display = 'none';
|
||||
}
|
||||
|
||||
// Benutzername (standardmäßig sichtbar; Auge schaltet Maskierung)
|
||||
const uval = e.username || '';
|
||||
const uEl = $('detailUser');
|
||||
uEl.dataset.value = uval;
|
||||
detailState.revealUser = true;
|
||||
if (uval) {
|
||||
uEl.classList.remove('empty');
|
||||
uEl.textContent = uval;
|
||||
$('btnRevealUser').style.display = '';
|
||||
$('btnCopyUser').style.display = '';
|
||||
} else {
|
||||
uEl.classList.add('empty');
|
||||
uEl.textContent = 'Kein Benutzername';
|
||||
$('btnRevealUser').style.display = 'none';
|
||||
$('btnCopyUser').style.display = 'none';
|
||||
}
|
||||
|
||||
// Passwort (standardmäßig maskiert)
|
||||
detailState.revealPass = false;
|
||||
$('detailPass').textContent = '••••••••••';
|
||||
|
||||
// Notizen
|
||||
const notes = (e.notes || '').trim();
|
||||
if (notes) {
|
||||
$('detailNotes').textContent = notes;
|
||||
$('detailNotes').dataset.value = notes;
|
||||
$('fieldNotes').style.display = '';
|
||||
} else {
|
||||
$('fieldNotes').style.display = 'none';
|
||||
}
|
||||
|
||||
// TOTP
|
||||
if (e.has_totp) {
|
||||
$('fieldTotp').style.display = '';
|
||||
loadDetailTotp(String(id));
|
||||
} else {
|
||||
$('fieldTotp').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
closeDetailTimers();
|
||||
detailState = null;
|
||||
$('detailPanel').style.display = 'none';
|
||||
$('listWrap').style.display = '';
|
||||
$('search').closest('.search-wrap').style.display = '';
|
||||
}
|
||||
|
||||
function toggleRevealUser() {
|
||||
const uEl = $('detailUser');
|
||||
const val = uEl.dataset.value || '';
|
||||
if (!val) return;
|
||||
detailState.revealUser = !detailState.revealUser;
|
||||
uEl.textContent = detailState.revealUser ? val : '•'.repeat(Math.min(val.length, 14));
|
||||
}
|
||||
|
||||
async function ensurePassword(id) {
|
||||
if (detailState && detailState.password !== null) return detailState.password;
|
||||
const pw = await new Promise(resolve => {
|
||||
chrome.runtime.sendMessage({ type: 'GET_PASSWORD', id }, resp => resolve(resp?.password ?? null));
|
||||
});
|
||||
if (detailState && detailState.id === String(id)) detailState.password = pw || '';
|
||||
return pw || '';
|
||||
}
|
||||
|
||||
async function toggleRevealPass() {
|
||||
const pEl = $('detailPass');
|
||||
if (detailState.revealPass) {
|
||||
detailState.revealPass = false;
|
||||
pEl.textContent = '••••••••••';
|
||||
return;
|
||||
}
|
||||
pEl.textContent = '…';
|
||||
const pw = await ensurePassword(detailState.id);
|
||||
if (!detailState) return;
|
||||
detailState.revealPass = true;
|
||||
pEl.textContent = pw || '(leer)';
|
||||
}
|
||||
|
||||
async function copyDetailPassword() {
|
||||
const pw = await ensurePassword(detailState.id);
|
||||
if (pw) copySecret(pw, 'Passwort kopiert');
|
||||
else showToast('Kein Passwort');
|
||||
}
|
||||
|
||||
function loadDetailTotp(id) {
|
||||
const codeEl = $('detailTotp');
|
||||
const secsEl = $('detailTotpSecs');
|
||||
const barEl = $('detailTotpBar');
|
||||
codeEl.textContent = '…';
|
||||
codeEl.dataset.code = '';
|
||||
secsEl.textContent = '';
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'GET_TOTP', id }, resp => {
|
||||
if (!detailState || detailState.id !== String(id)) return;
|
||||
if (!resp?.code) { codeEl.textContent = '—'; return; }
|
||||
|
||||
const apply = (code, remaining) => {
|
||||
codeEl.textContent = code.slice(0, 3) + ' ' + code.slice(3);
|
||||
codeEl.dataset.code = code;
|
||||
secsEl.textContent = remaining + 's';
|
||||
if (barEl) barEl.style.width = Math.round(remaining / 30 * 100) + '%';
|
||||
};
|
||||
apply(resp.code, resp.remaining);
|
||||
|
||||
let secs = resp.remaining;
|
||||
detailState.totpInterval = setInterval(() => {
|
||||
secs--;
|
||||
if (secs <= 0) {
|
||||
chrome.runtime.sendMessage({ type: 'GET_TOTP', id }, r2 => {
|
||||
if (!detailState || detailState.id !== String(id)) return;
|
||||
if (r2?.code) { secs = r2.remaining; apply(r2.code, r2.remaining); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
secsEl.textContent = secs + 's';
|
||||
if (barEl) {
|
||||
barEl.style.width = Math.round(secs / 30 * 100) + '%';
|
||||
barEl.style.background = secs < 10 ? '#dc3545' : '#34d399';
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
async function fillActiveTab() {
|
||||
if (!detailState) return;
|
||||
const e = entryIndex[detailState.id];
|
||||
if (!e) return;
|
||||
const btn = $('btnDetailFill');
|
||||
btn.disabled = true;
|
||||
const pw = await ensurePassword(detailState.id);
|
||||
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, tabs => {
|
||||
const tab = tabs[0];
|
||||
if (!tab || !tab.url || tab.url.startsWith('chrome://') || tab.url.startsWith('chrome-extension://')) {
|
||||
showToast('Auf dieser Seite nicht möglich');
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
// Sicherheit: Warnen, wenn die aktive Seite NICHT zur URL des Eintrags
|
||||
// passt (verhindert versehentliches Ausfüllen auf einer fremden Domain).
|
||||
if (!fillDomainMatches(e.url, tab.url)) {
|
||||
const host = hostOf(tab.url);
|
||||
if (!window.confirm('Diese Seite (' + host + ') passt nicht zur hinterlegten Adresse des Eintrags. Zugangsdaten trotzdem hier ausfüllen?')) {
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
chrome.tabs.sendMessage(tab.id, { type: 'VAULT_FILL', id: detailState.id, username: e.username || '', password: pw || '', has_totp: !!e.has_totp }, () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
showToast('Seite nicht bereit – neu laden');
|
||||
btn.disabled = false;
|
||||
} else {
|
||||
showToast('Ausgefüllt');
|
||||
setTimeout(() => window.close(), 350);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helfer ────────────────────────────────────────────────────────────────
|
||||
function hostOf(u) {
|
||||
try {
|
||||
const s = String(u || '');
|
||||
return new URL(s.includes('://') ? s : 'https://' + s).hostname.replace(/^www\./, '').toLowerCase();
|
||||
} catch { return ''; }
|
||||
}
|
||||
// True, wenn eine der (mehrzeiligen) Eintrags-URLs zur Seiten-Domain passt –
|
||||
// oder wenn im Eintrag gar keine URL hinterlegt ist (dann keine Warnung).
|
||||
function fillDomainMatches(entryUrls, pageUrl) {
|
||||
const pageHost = hostOf(pageUrl);
|
||||
const list = String(entryUrls || '').split('\n').map(s => s.trim()).filter(Boolean);
|
||||
if (!list.length) return true;
|
||||
if (!pageHost) return false;
|
||||
return list.some(u => {
|
||||
let eh = hostOf(u);
|
||||
if (eh.startsWith('*.')) eh = eh.slice(2);
|
||||
return eh && (pageHost === eh || pageHost.endsWith('.' + eh) || eh.endsWith('.' + pageHost));
|
||||
});
|
||||
}
|
||||
function copyToClipboard(text, msg) {
|
||||
navigator.clipboard.writeText(text).then(() => showToast(msg)).catch(() => showToast('Fehler'));
|
||||
}
|
||||
// Wie copyToClipboard, plant aber zusätzlich das automatische Leeren der
|
||||
// Zwischenablage (für Zugangsdaten/2FA).
|
||||
function copySecret(text, msg) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showToast(msg);
|
||||
chrome.runtime.sendMessage({ type: 'SCHEDULE_CLIP_CLEAR', text });
|
||||
}).catch(() => showToast('Fehler'));
|
||||
}
|
||||
function showToast(msg) {
|
||||
const t = $('toast');
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
setTimeout(() => t.classList.remove('show'), 1800);
|
||||
}
|
||||
function esc(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function escAttr(s) { return String(s||'').replace(/"/g,'"'); }
|
||||
|
||||
init();
|
||||
108
store/listing.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Chrome Web Store – Eintrag
|
||||
|
||||
Vorlagen für den Store-Eintrag. Vor Veröffentlichung Kontaktadresse/Datenschutz-URL ergänzen.
|
||||
|
||||
---
|
||||
|
||||
## Name
|
||||
|
||||
```
|
||||
OpenNIT Vault
|
||||
```
|
||||
|
||||
## Kurzbeschreibung (max. 132 Zeichen)
|
||||
|
||||
**DE**
|
||||
```
|
||||
Passwort-Manager für OpenNIT: Autofill für Login- und 2FA-Felder, Suche, PIN-Sperre – sicher aus deiner eigenen Instanz.
|
||||
```
|
||||
|
||||
**EN**
|
||||
```
|
||||
Password manager for OpenNIT: autofill for login and 2FA fields, search and PIN lock — served from your own instance.
|
||||
```
|
||||
|
||||
## Ausführliche Beschreibung
|
||||
|
||||
**DE**
|
||||
```
|
||||
OpenNIT Vault ist die offizielle Browser-Erweiterung zum Passwort-Tresor von OpenNIT – deiner
|
||||
selbst gehosteten IT-Dokumentation und Asset-Verwaltung.
|
||||
|
||||
FUNKTIONEN
|
||||
• Autofill von Benutzername, Passwort und 2FA-/TOTP-Codes – auch bei mehrstufigen Logins
|
||||
• Vorschläge direkt im Eingabefeld, passend zur aufgerufenen Website
|
||||
• Sofortsuche über persönliche und Team-Tresore
|
||||
• Detailansicht: Zugangsdaten anzeigen/kopieren, 2FA-Code mit Countdown
|
||||
• Neue Einträge anlegen inkl. Passwort-Generator
|
||||
• PIN-Sperre mit demselben PIN wie dein Web-Tresor (Dauer frei wählbar)
|
||||
• Zwischenablage wird nach dem Kopieren automatisch geleert
|
||||
• Favicons der Seiten, heller & dunkler Modus
|
||||
|
||||
SICHERHEIT & DATENSCHUTZ
|
||||
• Kein Remote-Code, keine Telemetrie, keine Drittanbieter-Server
|
||||
• Datenfluss ausschließlich zwischen Browser und deiner OpenNIT-Instanz
|
||||
• Passwörter werden erst im Moment der Nutzung abgerufen und nicht dauerhaft gespeichert
|
||||
|
||||
EINRICHTUNG
|
||||
Server-URL eintragen, im OpenNIT-Tresor einen API-Token erzeugen und einfügen – fertig.
|
||||
|
||||
OpenNIT Vault benötigt eine laufende OpenNIT-Instanz.
|
||||
```
|
||||
|
||||
**EN**
|
||||
```
|
||||
OpenNIT Vault is the official browser extension for the OpenNIT password vault — your self-hosted
|
||||
IT documentation and asset management platform.
|
||||
|
||||
FEATURES
|
||||
• Autofill for username, password and 2FA/TOTP codes, incl. multi-step logins
|
||||
• In-field suggestions matching the current website
|
||||
• Instant search across personal and team vaults
|
||||
• Detail view: reveal/copy credentials, 2FA code with countdown
|
||||
• Create new entries incl. password generator
|
||||
• PIN lock using the same PIN as your web vault (configurable duration)
|
||||
• Clipboard is cleared automatically after copying
|
||||
• Site favicons, light & dark mode
|
||||
|
||||
SECURITY & PRIVACY
|
||||
• No remote code, no telemetry, no third-party servers
|
||||
• Data flows only between your browser and your OpenNIT instance
|
||||
• Passwords are fetched only at the moment of use and never stored permanently
|
||||
|
||||
SETUP
|
||||
Enter your server URL, generate an API token in the OpenNIT vault, paste it — done.
|
||||
|
||||
OpenNIT Vault requires a running OpenNIT instance.
|
||||
```
|
||||
|
||||
## Kategorie
|
||||
|
||||
`Produktivität` (Productivity)
|
||||
|
||||
## Sprache(n)
|
||||
|
||||
Deutsch (primär), Englisch
|
||||
|
||||
## Assets
|
||||
|
||||
| Asset | Größe | Datei |
|
||||
|-------|-------|-------|
|
||||
| Store-Icon | 128×128 | `../extension/icon128.png` |
|
||||
| Screenshots | 1280×800 | `screenshots/01–05*.png` |
|
||||
|
||||
## Datenschutz-Angaben (Data usage im Dashboard)
|
||||
|
||||
- Erhobene Datentypen: **Authentifizierungsinformationen** (API-Token), **Website-Inhalte** nur für
|
||||
Autofill (verlassen den Browser nicht).
|
||||
- Nicht verkauft/weitergegeben. Nicht für Werbung/Bonität genutzt.
|
||||
- Verschlüsselte Übertragung (HTTPS).
|
||||
- **Datenschutzerklärung-URL:** _(vor Veröffentlichung eintragen — z. B. Rohlink zu PRIVACY.md)_
|
||||
|
||||
## Pflichtangaben vor Einreichung
|
||||
|
||||
- [ ] Datenschutzerklärung-URL hinterlegt
|
||||
- [ ] Kontakt-E-Mail im Developer-Konto verifiziert
|
||||
- [ ] Berechtigungen begründet (siehe `../docs/PERMISSIONS.md`)
|
||||
- [ ] Single-Purpose-Beschreibung angegeben
|
||||
- [ ] Screenshots hochgeladen
|
||||
BIN
store/screenshots/01-uebersicht.png
Normal file
|
After Width: | Height: | Size: 347 KiB |
BIN
store/screenshots/02-detail.png
Normal file
|
After Width: | Height: | Size: 342 KiB |
BIN
store/screenshots/03-pin.png
Normal file
|
After Width: | Height: | Size: 327 KiB |
BIN
store/screenshots/04-generator.png
Normal file
|
After Width: | Height: | Size: 336 KiB |
BIN
store/screenshots/05-darkmode.png
Normal file
|
After Width: | Height: | Size: 417 KiB |