diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8de2519 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f36a7b7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,662 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + + 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 . + +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 +. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..825b1e8 --- /dev/null +++ b/PRIVACY.md @@ -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: `): 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3bafda0 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +

+ OpenNIT Vault +

+ +

OpenNIT Vault – Browser-Erweiterung

+ +

+ Passwort-Manager-Erweiterung für OpenNIT – + Autofill für Benutzer-, Passwort- und 2FA-Felder direkt im Browser. +

+ +--- + +## 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) + +

+ + +

+ +## 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-.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. diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..6c495e5 --- /dev/null +++ b/build.sh @@ -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." diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..d2f2bfc --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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 `: + +- `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. diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..3d9e2f8 --- /dev/null +++ b/docs/INSTALL.md @@ -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. | diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md new file mode 100644 index 0000000..2d9e5f4 --- /dev/null +++ b/docs/PERMISSIONS.md @@ -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: [""]` + +**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 ``, `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://.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. diff --git a/docs/SSO-PLAN.md b/docs/SSO-PLAN.md new file mode 100644 index 0000000..a736b21 --- /dev/null +++ b/docs/SSO-PLAN.md @@ -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://.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://.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. diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..01b988c --- /dev/null +++ b/extension/background.js @@ -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; } +}); \ No newline at end of file diff --git a/extension/content.js b/extension/content.js new file mode 100644 index 0000000..667e6cf --- /dev/null +++ b/extension/content.js @@ -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, '>'); } +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 = ' ' + + 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 = ''; + } + }); + } + + const team = entry.team_name + ? '' + esc(entry.team_name) + '' + : ''; + const sub = mode === 'otp' + ? '2FA-Code einfügen' + : '
' + (esc(entry.username) || 'Kein Benutzername') + '
'; + + const info = document.createElement('div'); + info.style.cssText = 'flex:1;min-width:0;'; + info.innerHTML = '
' + + esc(entry.title) + '
' + 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 = + '
🔑' + + '2FA-Code kopiert
' + + '
' + esc(formatted) + '
' + + '
' + + '
' + + '' + remaining + 's
'; + 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(); +} \ No newline at end of file diff --git a/extension/icon128.png b/extension/icon128.png new file mode 100644 index 0000000..564e7e2 Binary files /dev/null and b/extension/icon128.png differ diff --git a/extension/icon16.png b/extension/icon16.png new file mode 100644 index 0000000..5d0b3cd Binary files /dev/null and b/extension/icon16.png differ diff --git a/extension/icon32.png b/extension/icon32.png new file mode 100644 index 0000000..882a6a7 Binary files /dev/null and b/extension/icon32.png differ diff --git a/extension/icon48.png b/extension/icon48.png new file mode 100644 index 0000000..4dd8132 Binary files /dev/null and b/extension/icon48.png differ diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..cae0877 --- /dev/null +++ b/extension/manifest.json @@ -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": [ + "" + ], + "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": [ + "" + ], + "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" + } +} \ No newline at end of file diff --git a/extension/offscreen.html b/extension/offscreen.html new file mode 100644 index 0000000..514d646 --- /dev/null +++ b/extension/offscreen.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/extension/offscreen.js b/extension/offscreen.js new file mode 100644 index 0000000..c52c5aa --- /dev/null +++ b/extension/offscreen.js @@ -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 = ''; + }); + } +}); \ No newline at end of file diff --git a/extension/options.html b/extension/options.html new file mode 100644 index 0000000..85b50a4 --- /dev/null +++ b/extension/options.html @@ -0,0 +1,304 @@ + + + + + +OpenNIT Vault – Einstellungen + + + + + + + +
+ + +
+
+

Server-Verbindung

+
+
+
+ + + Adresse Ihrer OpenNIT-Instanz – ohne abschließenden Slash. +
+ + +
+ + + +
+ Empfohlen: Anmeldung wie an OpenNIT (lokal + 2FA / Microsoft 365 / Keycloak). Der Zugang wird automatisch erneuert. +
+ + +
+ Erweitert: manueller Token +
+
+ + + Alternative ohne SSO (z. B. Kiosk/Headless): Vault → „Extension“ → Token generieren. +
+
+ + + +
+
+
+
+
+ + +
+
+

Sicherheit

+
+
+
+ + + Ist im Web-Tresor ein Tresor-PIN aktiv, verlangt die Erweiterung diesen PIN – serverseitig erzwungen, damit ein gestohlener Token allein nichts nützt. Diese Einstellung legt nur fest, wie lange eine Entsperrung gilt. Ohne gesetzten Tresor-PIN entfällt die Sperre. +
+
+ + Kopierte Passwörter und 2FA-Codes werden nach kurzer Zeit automatisch aus der Zwischenablage entfernt. +
+
+ + +
+
+
+ + +
+
+

Einrichtungsschritte

+
+
+
    +
  • 1Öffnen Sie den Passwort-Vault in Ihrer OpenNIT-Anwendung.
  • +
  • 2Klicken Sie auf „Extension“ in der Toolbar und generieren Sie einen API-Token.
  • +
  • 3Fügen Sie den Token oben in das Token-Feld ein und klicken Sie auf Speichern.
  • +
  • 4Fokussieren Sie ein Login-Feld auf einer Website – Vault-Vorschläge erscheinen automatisch.
  • +
+
+
+ +
+ + + + \ No newline at end of file diff --git a/extension/options.js b/extension/options.js new file mode 100644 index 0000000..25181f6 --- /dev/null +++ b/extension/options.js @@ -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 = '✓ Gespeichert'; + 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 https://-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 = '✓ Gespeichert'; + 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 = ' 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 ${esc(data.user)}`, 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 ? ('' + esc(msg) + '') : ''; return; } + el.innerHTML = ok ? ('✓ ' + esc(msg) + '') + : ('' + esc(msg) + ''); + 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 = `
${msg}
`; + setTimeout(() => { el.innerHTML = ''; }, 5000); +} + +function esc(s) { return String(s||'').replace(/&/g,'&').replace(//g,'>'); } \ No newline at end of file diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 0000000..c6f1a77 --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,347 @@ + + + + + + + + +
+
+
+
OpenNIT Vault
+
+
+
+
+ + +
+ +
+ + +
+ + +
+
+ Neuen Eintrag anlegen + +
+
+ + +
+ + + +
+ + + +
+
+
+ + +
+
+ + Eintrag + +
+
+
+
+
+
+ +
+
+ +
+
Benutzername
+
+ + + +
+
+ +
+
Passwort
+
+ •••••••••• + + +
+
+ + + + + + +
+
+ + +
+
+
+ +
+
Tresor gesperrt
+
Mit deinem Tresor-PIN entsperren
+ + +
+
+
+ + +
+ + + + + + + + + + + + +
+ +
+ + + \ No newline at end of file diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 0000000..545e113 --- /dev/null +++ b/extension/popup.js @@ -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 = '
'; + $('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 = '
⚠ Nicht verbunden.
Einstellungen prüfen.
'; + 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 = + '
Passend für diese Seite
' + + '
' + pageMatches.map(e => entryHtml(e)).join('') + '
'; + } else { + $('listWrap').innerHTML = + '
Alle Einträge (' + allEntries.length + ')
' + + '
' + + (allEntries.length ? allEntries.map(e => entryHtml(e)).join('') : '
Noch keine Einträge vorhanden.
') + + '
'; + } + 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 = + '
Suche (' + filtered.length + ')
' + + '
' + + (filtered.length ? filtered.map(e => entryHtml(e)).join('') : '
Keine Einträge gefunden.
') + + '
'; + + 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) || 'Kein Benutzername'; + const m = monogram(e.title); + const icon = `${m.ch}`; + const totpBadge = e.has_totp ? '2FA' : ''; + return ` +
+
${icon}
+ + +
+ `; +} + +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 = ''; + } + }); + }); +} + +// ── 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 = `${m.ch}`; + 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 = ''; + } + }); + } + + // 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,'>'); } +function escAttr(s) { return String(s||'').replace(/"/g,'"'); } + +init(); \ No newline at end of file diff --git a/store/listing.md b/store/listing.md new file mode 100644 index 0000000..8de2f66 --- /dev/null +++ b/store/listing.md @@ -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 diff --git a/store/screenshots/01-uebersicht.png b/store/screenshots/01-uebersicht.png new file mode 100644 index 0000000..c70cde2 Binary files /dev/null and b/store/screenshots/01-uebersicht.png differ diff --git a/store/screenshots/02-detail.png b/store/screenshots/02-detail.png new file mode 100644 index 0000000..4848430 Binary files /dev/null and b/store/screenshots/02-detail.png differ diff --git a/store/screenshots/03-pin.png b/store/screenshots/03-pin.png new file mode 100644 index 0000000..67f4a98 Binary files /dev/null and b/store/screenshots/03-pin.png differ diff --git a/store/screenshots/04-generator.png b/store/screenshots/04-generator.png new file mode 100644 index 0000000..03d951b Binary files /dev/null and b/store/screenshots/04-generator.png differ diff --git a/store/screenshots/05-darkmode.png b/store/screenshots/05-darkmode.png new file mode 100644 index 0000000..6051a65 Binary files /dev/null and b/store/screenshots/05-darkmode.png differ