parent
b8c534cd0d
commit
d19a4348df
@ -0,0 +1,20 @@
|
|||||||
|
Copyright © 2013 Keith Rarick
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of this software and associated documentation files (the
|
||||||
|
"Software"), to deal in the Software without restriction, including
|
||||||
|
without limitation the rights to use, copy, modify, merge, publish,
|
||||||
|
distribute, sublicense, and/or sell copies of the Software, and to
|
||||||
|
permit persons to whom the Software is furnished to do so, subject to
|
||||||
|
the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be
|
||||||
|
included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||||
|
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||||
|
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||||
|
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
@ -0,0 +1,22 @@
|
|||||||
|
Fernet takes a user-provided *message* (an arbitrary sequence of
|
||||||
|
bytes), a *key* (256 bits), and the current time, and produces a
|
||||||
|
*token*, which contains the message in a form that can't be read
|
||||||
|
or altered without the key.
|
||||||
|
|
||||||
|
This package is compatible with the other implementations at
|
||||||
|
https://github.com/fernet. They can exchange tokens freely among
|
||||||
|
each other.
|
||||||
|
|
||||||
|
Documentation: http://godoc.org/github.com/fernet/fernet-go
|
||||||
|
|
||||||
|
|
||||||
|
INSTALL
|
||||||
|
|
||||||
|
$ go get github.com/fernet/fernet-go
|
||||||
|
|
||||||
|
|
||||||
|
For more information and background, see the Fernet spec at
|
||||||
|
https://github.com/fernet/spec.
|
||||||
|
|
||||||
|
Fernet is distributed under the terms of the MIT license.
|
||||||
|
See the License file for details.
|
@ -0,0 +1,19 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/fernet/fernet-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(0)
|
||||||
|
log.SetPrefix("fernet: ")
|
||||||
|
|
||||||
|
var key fernet.Key
|
||||||
|
if err := key.Generate(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println(key.Encode())
|
||||||
|
}
|
@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/fernet/fernet-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Usage = `Usage: fernet-sign ENV
|
||||||
|
|
||||||
|
fernet-sign encrypts and signs its input and prints the resulting token.
|
||||||
|
It uses the key in environment variable ENV.`
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(0)
|
||||||
|
log.SetPrefix("fernet: ")
|
||||||
|
|
||||||
|
if len(os.Args) != 2 {
|
||||||
|
fmt.Fprintln(os.Stderr, Usage)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := fernet.DecodeKey(os.Getenv(os.Args[1]))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := ioutil.ReadAll(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := fernet.EncryptAndSign(b, key)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = os.Stdout.Write(append(t, '\n'))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,168 @@
|
|||||||
|
// Package fernet takes a user-provided message (an arbitrary
|
||||||
|
// sequence of bytes), a key (256 bits), and the current time,
|
||||||
|
// and produces a token, which contains the message in a form
|
||||||
|
// that can't be read or altered without the key.
|
||||||
|
//
|
||||||
|
// For more information and background, see the Fernet spec
|
||||||
|
// at https://github.com/fernet/spec.
|
||||||
|
//
|
||||||
|
// Subdirectories in this package provide command-line tools
|
||||||
|
// for working with Fernet keys and tokens.
|
||||||
|
package fernet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
version byte = 0x80
|
||||||
|
tsOffset = 1
|
||||||
|
ivOffset = tsOffset + 8
|
||||||
|
payOffset = ivOffset + aes.BlockSize
|
||||||
|
overhead = 1 + 8 + aes.BlockSize + sha256.Size // ver + ts + iv + hmac
|
||||||
|
maxClockSkew = 60 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var encoding = base64.URLEncoding
|
||||||
|
|
||||||
|
// generates a token from msg, writes it into tok, and returns the
|
||||||
|
// number of bytes generated, which is encodedLen(msg).
|
||||||
|
// len(tok) must be >= encodedLen(len(msg))
|
||||||
|
func gen(tok, msg, iv []byte, ts time.Time, k *Key) int {
|
||||||
|
tok[0] = version
|
||||||
|
binary.BigEndian.PutUint64(tok[tsOffset:], uint64(ts.Unix()))
|
||||||
|
copy(tok[ivOffset:], iv)
|
||||||
|
p := tok[payOffset:]
|
||||||
|
n := pad(p, msg, aes.BlockSize)
|
||||||
|
bc, _ := aes.NewCipher(k.cryptBytes())
|
||||||
|
cipher.NewCBCEncrypter(bc, iv).CryptBlocks(p[:n], p[:n])
|
||||||
|
genhmac(p[n:n], tok[:payOffset+n], k.signBytes())
|
||||||
|
return payOffset + n + sha256.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
// token length for input msg of length n, not including base64
|
||||||
|
func encodedLen(n int) int {
|
||||||
|
const k = aes.BlockSize
|
||||||
|
return n/k*k + k + overhead
|
||||||
|
}
|
||||||
|
|
||||||
|
// max msg length for tok of length n, for binary token (no base64)
|
||||||
|
// upper bound; not exact
|
||||||
|
func decodedLen(n int) int {
|
||||||
|
return n - overhead
|
||||||
|
}
|
||||||
|
|
||||||
|
// if msg is nil, decrypts in place and returns a slice of tok.
|
||||||
|
func verify(msg, tok []byte, ttl time.Duration, now time.Time, k *Key) []byte {
|
||||||
|
if len(tok) < 1 || tok[0] != version {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ts := time.Unix(int64(binary.BigEndian.Uint64(tok[1:])), 0)
|
||||||
|
if ttl >= 0 && (now.After(ts.Add(ttl)) || ts.After(now.Add(maxClockSkew))) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n := len(tok) - sha256.Size
|
||||||
|
var hmac [sha256.Size]byte
|
||||||
|
genhmac(hmac[:0], tok[:n], k.signBytes())
|
||||||
|
if subtle.ConstantTimeCompare(tok[n:], hmac[:]) != 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pay := tok[payOffset : len(tok)-sha256.Size]
|
||||||
|
if len(pay)%aes.BlockSize != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if msg != nil {
|
||||||
|
copy(msg, pay)
|
||||||
|
pay = msg
|
||||||
|
}
|
||||||
|
bc, _ := aes.NewCipher(k.cryptBytes())
|
||||||
|
iv := tok[9:][:aes.BlockSize]
|
||||||
|
cipher.NewCBCDecrypter(bc, iv).CryptBlocks(pay, pay)
|
||||||
|
return unpad(pay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pads p to a multiple of k using PKCS #7 standard block padding.
|
||||||
|
// See http://tools.ietf.org/html/rfc5652#section-6.3.
|
||||||
|
func pad(q, p []byte, k int) int {
|
||||||
|
n := len(p)/k*k + k
|
||||||
|
copy(q, p)
|
||||||
|
c := byte(n - len(p))
|
||||||
|
for i := len(p); i < n; i++ {
|
||||||
|
q[i] = c
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removes PKCS #7 standard block padding from p.
|
||||||
|
// See http://tools.ietf.org/html/rfc5652#section-6.3.
|
||||||
|
// This function is the inverse of pad.
|
||||||
|
// If the padding is not well-formed, unpad returns nil.
|
||||||
|
func unpad(p []byte) []byte {
|
||||||
|
c := p[len(p)-1]
|
||||||
|
for i := len(p) - int(c); i < len(p); i++ {
|
||||||
|
if i < 0 || p[i] != c {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p[:len(p)-int(c)]
|
||||||
|
}
|
||||||
|
|
||||||
|
func b64enc(src []byte) []byte {
|
||||||
|
dst := make([]byte, encoding.EncodedLen(len(src)))
|
||||||
|
encoding.Encode(dst, src)
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func b64dec(src []byte) []byte {
|
||||||
|
dst := make([]byte, encoding.DecodedLen(len(src)))
|
||||||
|
n, err := encoding.Decode(dst, src)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return dst[:n]
|
||||||
|
}
|
||||||
|
|
||||||
|
func genhmac(q, p, k []byte) {
|
||||||
|
h := hmac.New(sha256.New, k)
|
||||||
|
h.Write(p)
|
||||||
|
h.Sum(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptAndSign encrypts and signs msg with key k and returns the resulting
|
||||||
|
// fernet token. If msg contains text, the text should be encoded
|
||||||
|
// with UTF-8 to follow fernet convention.
|
||||||
|
func EncryptAndSign(msg []byte, k *Key) (tok []byte, err error) {
|
||||||
|
iv := make([]byte, aes.BlockSize)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b := make([]byte, encodedLen(len(msg)))
|
||||||
|
n := gen(b, msg, iv, time.Now(), k)
|
||||||
|
tok = make([]byte, encoding.EncodedLen(n))
|
||||||
|
encoding.Encode(tok, b[:n])
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyAndDecrypt verifies that tok is a valid fernet token that was signed
|
||||||
|
// with a key in k at most ttl time ago only if ttl is greater than zero.
|
||||||
|
// Returns the message contained in tok if tok is valid, otherwise nil.
|
||||||
|
func VerifyAndDecrypt(tok []byte, ttl time.Duration, k []*Key) (msg []byte) {
|
||||||
|
b := make([]byte, encoding.DecodedLen(len(tok)))
|
||||||
|
n, _ := encoding.Decode(b, tok)
|
||||||
|
for _, k1 := range k {
|
||||||
|
msg = verify(nil, b[:n], ttl, time.Now(), k1)
|
||||||
|
if msg != nil {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"token": "gAAAAAAdwJ6wAAECAwQFBgcICQoLDA0ODy021cpGVWKZ_eEwCGM4BLLF_5CV9dOPmrhuVUPgJobwOz7JcbmrR64jVmpU4IwqDA==",
|
||||||
|
"now": "1985-10-26T01:20:00-07:00",
|
||||||
|
"iv": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||||
|
"src": "hello",
|
||||||
|
"secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
|
||||||
|
}
|
||||||
|
]
|
@ -0,0 +1,58 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"desc": "incorrect mac",
|
||||||
|
"token": "gAAAAAAdwJ6xAAECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPAl1-szkFVzXTuGb4hR8AKtwcaX1YdykQUFBQUFBQUFBQQ==",
|
||||||
|
"now": "1985-10-26T01:20:01-07:00",
|
||||||
|
"ttl_sec": 60,
|
||||||
|
"secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"desc": "too short",
|
||||||
|
"token": "gAAAAAAdwJ6xAAECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPA==",
|
||||||
|
"now": "1985-10-26T01:20:01-07:00",
|
||||||
|
"ttl_sec": 60,
|
||||||
|
"secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"desc": "invalid base64",
|
||||||
|
"token": "%%%%%%%%%%%%%AECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPAl1-szkFVzXTuGb4hR8AKtwcaX1YdykRtfsH-p1YsUD2Q==",
|
||||||
|
"now": "1985-10-26T01:20:01-07:00",
|
||||||
|
"ttl_sec": 60,
|
||||||
|
"secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"desc": "payload size not multiple of block size",
|
||||||
|
"token": "gAAAAAAdwJ6xAAECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPOm73QeoCk9uGib28Xe5vz6oxq5nmxbx_v7mrfyudzUm",
|
||||||
|
"now": "1985-10-26T01:20:01-07:00",
|
||||||
|
"ttl_sec": 60,
|
||||||
|
"secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"desc": "payload padding error",
|
||||||
|
"token": "gAAAAAAdwJ6xAAECAwQFBgcICQoLDA0ODz4LEpdELGQAad7aNEHbf-JkLPIpuiYRLQ3RtXatOYREu2FWke6CnJNYIbkuKNqOhw==",
|
||||||
|
"now": "1985-10-26T01:20:01-07:00",
|
||||||
|
"ttl_sec": 60,
|
||||||
|
"secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"desc": "far-future TS (unacceptable clock skew)",
|
||||||
|
"token": "gAAAAAAdwStRAAECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPAnja1xKYyhd-Y6mSkTOyTGJmw2Xc2a6kBd-iX9b_qXQcw==",
|
||||||
|
"now": "1985-10-26T01:20:01-07:00",
|
||||||
|
"ttl_sec": 60,
|
||||||
|
"secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"desc": "expired TTL",
|
||||||
|
"token": "gAAAAAAdwJ6xAAECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPAl1-szkFVzXTuGb4hR8AKtwcaX1YdykRtfsH-p1YsUD2Q==",
|
||||||
|
"now": "1985-10-26T01:21:31-07:00",
|
||||||
|
"ttl_sec": 60,
|
||||||
|
"secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"desc": "incorrect IV (causes padding error)",
|
||||||
|
"token": "gAAAAAAdwJ6xBQECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPAkLhFLHpGtDBRLRTZeUfWgHSv49TF2AUEZ1TIvcZjK1zQ==",
|
||||||
|
"now": "1985-10-26T01:20:01-07:00",
|
||||||
|
"ttl_sec": 60,
|
||||||
|
"secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
|
||||||
|
}
|
||||||
|
]
|
@ -0,0 +1,91 @@
|
|||||||
|
package fernet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errKeyLen = errors.New("fernet: key decodes to wrong size")
|
||||||
|
errNoKeys = errors.New("fernet: no keys provided")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Key represents a key.
|
||||||
|
type Key [32]byte
|
||||||
|
|
||||||
|
func (k *Key) cryptBytes() []byte {
|
||||||
|
return k[len(k)/2:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Key) signBytes() []byte {
|
||||||
|
return k[:len(k)/2]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate initializes k with pseudorandom data from package crypto/rand.
|
||||||
|
func (k *Key) Generate() error {
|
||||||
|
_, err := io.ReadFull(rand.Reader, k[:])
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode returns the URL-safe base64 encoding of k.
|
||||||
|
func (k *Key) Encode() string {
|
||||||
|
return encoding.EncodeToString(k[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeKey decodes a key from s and returns it. The key can be in
|
||||||
|
// hexadecimal, standard base64, or URL-safe base64.
|
||||||
|
func DecodeKey(s string) (*Key, error) {
|
||||||
|
var b []byte
|
||||||
|
var err error
|
||||||
|
if s == "" {
|
||||||
|
return nil, errors.New("empty key")
|
||||||
|
}
|
||||||
|
if len(s) == hex.EncodedLen(len(Key{})) {
|
||||||
|
b, err = hex.DecodeString(s)
|
||||||
|
} else {
|
||||||
|
b, err = base64.StdEncoding.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
b, err = base64.URLEncoding.DecodeString(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(b) != len(Key{}) {
|
||||||
|
return nil, errKeyLen
|
||||||
|
}
|
||||||
|
k := new(Key)
|
||||||
|
copy(k[:], b)
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeKeys decodes each element of a using DecodeKey and returns the
|
||||||
|
// resulting keys. Requires at least one key.
|
||||||
|
func DecodeKeys(a ...string) ([]*Key, error) {
|
||||||
|
if len(a) == 0 {
|
||||||
|
return nil, errNoKeys
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
ks := make([]*Key, len(a))
|
||||||
|
for i, s := range a {
|
||||||
|
ks[i], err = DecodeKey(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustDecodeKeys is like DecodeKeys, but panics if an error occurs.
|
||||||
|
// It simplifies safe initialization of global variables holding
|
||||||
|
// keys.
|
||||||
|
func MustDecodeKeys(a ...string) []*Key {
|
||||||
|
k, err := DecodeKeys(a...)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"token": "gAAAAAAdwJ6wAAECAwQFBgcICQoLDA0ODy021cpGVWKZ_eEwCGM4BLLF_5CV9dOPmrhuVUPgJobwOz7JcbmrR64jVmpU4IwqDA==",
|
||||||
|
"now": "1985-10-26T01:20:01-07:00",
|
||||||
|
"ttl_sec": 60,
|
||||||
|
"src": "hello",
|
||||||
|
"secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"token": "gAAAAAAdwJ6wAAECAwQFBgcICQoLDA0ODy021cpGVWKZ_eEwCGM4BLLF_5CV9dOPmrhuVUPgJobwOz7JcbmrR64jVmpU4IwqDA==",
|
||||||
|
"now": "1985-10-26T01:20:01-07:00",
|
||||||
|
"ttl_sec": -1,
|
||||||
|
"src": "hello",
|
||||||
|
"secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
|
||||||
|
}
|
||||||
|
]
|
Loading…
Reference in new issue