1/*
2 * Copyright (C) 2014 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License
15 */
16
17package com.android.server;
18
19import static android.content.Context.USER_SERVICE;
20
21import android.content.ContentValues;
22import android.content.Context;
23import android.content.pm.UserInfo;
24import android.database.Cursor;
25import android.database.sqlite.SQLiteDatabase;
26import android.database.sqlite.SQLiteOpenHelper;
27import android.os.Environment;
28import android.os.UserManager;
29import android.os.storage.StorageManager;
30import android.util.ArrayMap;
31import android.util.Log;
32import android.util.Slog;
33
34import com.android.internal.annotations.VisibleForTesting;
35import com.android.internal.util.ArrayUtils;
36import com.android.internal.widget.LockPatternUtils;
37
38import java.io.File;
39import java.io.IOException;
40import java.io.RandomAccessFile;
41
42/**
43 * Storage for the lock settings service.
44 */
45class LockSettingsStorage {
46
47    private static final String TAG = "LockSettingsStorage";
48    private static final String TABLE = "locksettings";
49    private static final boolean DEBUG = false;
50
51    private static final String COLUMN_KEY = "name";
52    private static final String COLUMN_USERID = "user";
53    private static final String COLUMN_VALUE = "value";
54
55    private static final String[] COLUMNS_FOR_QUERY = {
56            COLUMN_VALUE
57    };
58    private static final String[] COLUMNS_FOR_PREFETCH = {
59            COLUMN_KEY, COLUMN_VALUE
60    };
61
62    private static final String SYSTEM_DIRECTORY = "/system/";
63    private static final String LOCK_PATTERN_FILE = "gatekeeper.pattern.key";
64    private static final String BASE_ZERO_LOCK_PATTERN_FILE = "gatekeeper.gesture.key";
65    private static final String LEGACY_LOCK_PATTERN_FILE = "gesture.key";
66    private static final String LOCK_PASSWORD_FILE = "gatekeeper.password.key";
67    private static final String LEGACY_LOCK_PASSWORD_FILE = "password.key";
68    private static final String CHILD_PROFILE_LOCK_FILE = "gatekeeper.profile.key";
69
70    private static final String SYNTHETIC_PASSWORD_DIRECTORY = "spblob/";
71
72    private static final Object DEFAULT = new Object();
73
74    private final DatabaseHelper mOpenHelper;
75    private final Context mContext;
76    private final Cache mCache = new Cache();
77    private final Object mFileWriteLock = new Object();
78
79    @VisibleForTesting
80    public static class CredentialHash {
81        static final int VERSION_LEGACY = 0;
82        static final int VERSION_GATEKEEPER = 1;
83
84        private CredentialHash(byte[] hash, int type, int version) {
85            if (type != LockPatternUtils.CREDENTIAL_TYPE_NONE) {
86                if (hash == null) {
87                    throw new RuntimeException("Empty hash for CredentialHash");
88                }
89            } else /* type == LockPatternUtils.CREDENTIAL_TYPE_NONE */ {
90                if (hash != null) {
91                    throw new RuntimeException("None type CredentialHash should not have hash");
92                }
93            }
94            this.hash = hash;
95            this.type = type;
96            this.version = version;
97            this.isBaseZeroPattern = false;
98        }
99
100        private CredentialHash(byte[] hash, boolean isBaseZeroPattern) {
101            this.hash = hash;
102            this.type = LockPatternUtils.CREDENTIAL_TYPE_PATTERN;
103            this.version = VERSION_GATEKEEPER;
104            this.isBaseZeroPattern = isBaseZeroPattern;
105        }
106
107        static CredentialHash create(byte[] hash, int type) {
108            if (type == LockPatternUtils.CREDENTIAL_TYPE_NONE) {
109                throw new RuntimeException("Bad type for CredentialHash");
110            }
111            return new CredentialHash(hash, type, VERSION_GATEKEEPER);
112        }
113
114        static CredentialHash createEmptyHash() {
115            return new CredentialHash(null, LockPatternUtils.CREDENTIAL_TYPE_NONE,
116                    VERSION_GATEKEEPER);
117        }
118
119        byte[] hash;
120        int type;
121        int version;
122        boolean isBaseZeroPattern;
123    }
124
125    public LockSettingsStorage(Context context) {
126        mContext = context;
127        mOpenHelper = new DatabaseHelper(context);
128    }
129
130    public void setDatabaseOnCreateCallback(Callback callback) {
131        mOpenHelper.setCallback(callback);
132    }
133
134    public void writeKeyValue(String key, String value, int userId) {
135        writeKeyValue(mOpenHelper.getWritableDatabase(), key, value, userId);
136    }
137
138    public void writeKeyValue(SQLiteDatabase db, String key, String value, int userId) {
139        ContentValues cv = new ContentValues();
140        cv.put(COLUMN_KEY, key);
141        cv.put(COLUMN_USERID, userId);
142        cv.put(COLUMN_VALUE, value);
143
144        db.beginTransaction();
145        try {
146            db.delete(TABLE, COLUMN_KEY + "=? AND " + COLUMN_USERID + "=?",
147                    new String[] {key, Integer.toString(userId)});
148            db.insert(TABLE, null, cv);
149            db.setTransactionSuccessful();
150            mCache.putKeyValue(key, value, userId);
151        } finally {
152            db.endTransaction();
153        }
154
155    }
156
157    public String readKeyValue(String key, String defaultValue, int userId) {
158        int version;
159        synchronized (mCache) {
160            if (mCache.hasKeyValue(key, userId)) {
161                return mCache.peekKeyValue(key, defaultValue, userId);
162            }
163            version = mCache.getVersion();
164        }
165
166        Cursor cursor;
167        Object result = DEFAULT;
168        SQLiteDatabase db = mOpenHelper.getReadableDatabase();
169        if ((cursor = db.query(TABLE, COLUMNS_FOR_QUERY,
170                COLUMN_USERID + "=? AND " + COLUMN_KEY + "=?",
171                new String[] { Integer.toString(userId), key },
172                null, null, null)) != null) {
173            if (cursor.moveToFirst()) {
174                result = cursor.getString(0);
175            }
176            cursor.close();
177        }
178        mCache.putKeyValueIfUnchanged(key, result, userId, version);
179        return result == DEFAULT ? defaultValue : (String) result;
180    }
181
182    public void prefetchUser(int userId) {
183        int version;
184        synchronized (mCache) {
185            if (mCache.isFetched(userId)) {
186                return;
187            }
188            mCache.setFetched(userId);
189            version = mCache.getVersion();
190        }
191
192        Cursor cursor;
193        SQLiteDatabase db = mOpenHelper.getReadableDatabase();
194        if ((cursor = db.query(TABLE, COLUMNS_FOR_PREFETCH,
195                COLUMN_USERID + "=?",
196                new String[] { Integer.toString(userId) },
197                null, null, null)) != null) {
198            while (cursor.moveToNext()) {
199                String key = cursor.getString(0);
200                String value = cursor.getString(1);
201                mCache.putKeyValueIfUnchanged(key, value, userId, version);
202            }
203            cursor.close();
204        }
205
206        // Populate cache by reading the password and pattern files.
207        readCredentialHash(userId);
208    }
209
210    private CredentialHash readPasswordHashIfExists(int userId) {
211        byte[] stored = readFile(getLockPasswordFilename(userId));
212        if (!ArrayUtils.isEmpty(stored)) {
213            return new CredentialHash(stored, LockPatternUtils.CREDENTIAL_TYPE_PASSWORD,
214                    CredentialHash.VERSION_GATEKEEPER);
215        }
216
217        stored = readFile(getLegacyLockPasswordFilename(userId));
218        if (!ArrayUtils.isEmpty(stored)) {
219            return new CredentialHash(stored, LockPatternUtils.CREDENTIAL_TYPE_PASSWORD,
220                    CredentialHash.VERSION_LEGACY);
221        }
222        return null;
223    }
224
225    private CredentialHash readPatternHashIfExists(int userId) {
226        byte[] stored = readFile(getLockPatternFilename(userId));
227        if (!ArrayUtils.isEmpty(stored)) {
228            return new CredentialHash(stored, LockPatternUtils.CREDENTIAL_TYPE_PATTERN,
229                    CredentialHash.VERSION_GATEKEEPER);
230        }
231
232        stored = readFile(getBaseZeroLockPatternFilename(userId));
233        if (!ArrayUtils.isEmpty(stored)) {
234            return new CredentialHash(stored, true);
235        }
236
237        stored = readFile(getLegacyLockPatternFilename(userId));
238        if (!ArrayUtils.isEmpty(stored)) {
239            return new CredentialHash(stored, LockPatternUtils.CREDENTIAL_TYPE_PATTERN,
240                    CredentialHash.VERSION_LEGACY);
241        }
242        return null;
243    }
244
245    public CredentialHash readCredentialHash(int userId) {
246        CredentialHash passwordHash = readPasswordHashIfExists(userId);
247        CredentialHash patternHash = readPatternHashIfExists(userId);
248        if (passwordHash != null && patternHash != null) {
249            if (passwordHash.version == CredentialHash.VERSION_GATEKEEPER) {
250                return passwordHash;
251            } else {
252                return patternHash;
253            }
254        } else if (passwordHash != null) {
255            return passwordHash;
256        } else if (patternHash != null) {
257            return patternHash;
258        } else {
259            return CredentialHash.createEmptyHash();
260        }
261    }
262
263    public void removeChildProfileLock(int userId) {
264        if (DEBUG)
265            Slog.e(TAG, "Remove child profile lock for user: " + userId);
266        try {
267            deleteFile(getChildProfileLockFile(userId));
268        } catch (Exception e) {
269            e.printStackTrace();
270        }
271    }
272
273    public void writeChildProfileLock(int userId, byte[] lock) {
274        writeFile(getChildProfileLockFile(userId), lock);
275    }
276
277    public byte[] readChildProfileLock(int userId) {
278        return readFile(getChildProfileLockFile(userId));
279    }
280
281    public boolean hasChildProfileLock(int userId) {
282        return hasFile(getChildProfileLockFile(userId));
283    }
284
285    public boolean hasPassword(int userId) {
286        return hasFile(getLockPasswordFilename(userId)) ||
287            hasFile(getLegacyLockPasswordFilename(userId));
288    }
289
290    public boolean hasPattern(int userId) {
291        return hasFile(getLockPatternFilename(userId)) ||
292            hasFile(getBaseZeroLockPatternFilename(userId)) ||
293            hasFile(getLegacyLockPatternFilename(userId));
294    }
295
296    public boolean hasCredential(int userId) {
297        return hasPassword(userId) || hasPattern(userId);
298    }
299
300    private boolean hasFile(String name) {
301        byte[] contents = readFile(name);
302        return contents != null && contents.length > 0;
303    }
304
305    private byte[] readFile(String name) {
306        int version;
307        synchronized (mCache) {
308            if (mCache.hasFile(name)) {
309                return mCache.peekFile(name);
310            }
311            version = mCache.getVersion();
312        }
313
314        RandomAccessFile raf = null;
315        byte[] stored = null;
316        try {
317            raf = new RandomAccessFile(name, "r");
318            stored = new byte[(int) raf.length()];
319            raf.readFully(stored, 0, stored.length);
320            raf.close();
321        } catch (IOException e) {
322            Slog.e(TAG, "Cannot read file " + e);
323        } finally {
324            if (raf != null) {
325                try {
326                    raf.close();
327                } catch (IOException e) {
328                    Slog.e(TAG, "Error closing file " + e);
329                }
330            }
331        }
332        mCache.putFileIfUnchanged(name, stored, version);
333        return stored;
334    }
335
336    private void writeFile(String name, byte[] hash) {
337        synchronized (mFileWriteLock) {
338            RandomAccessFile raf = null;
339            try {
340                // Write the hash to file, requiring each write to be synchronized to the
341                // underlying storage device immediately to avoid data loss in case of power loss.
342                // This also ensures future secdiscard operation on the file succeeds since the
343                // file would have been allocated on flash.
344                raf = new RandomAccessFile(name, "rws");
345                // Truncate the file if pattern is null, to clear the lock
346                if (hash == null || hash.length == 0) {
347                    raf.setLength(0);
348                } else {
349                    raf.write(hash, 0, hash.length);
350                }
351                raf.close();
352            } catch (IOException e) {
353                Slog.e(TAG, "Error writing to file " + e);
354            } finally {
355                if (raf != null) {
356                    try {
357                        raf.close();
358                    } catch (IOException e) {
359                        Slog.e(TAG, "Error closing file " + e);
360                    }
361                }
362            }
363            mCache.putFile(name, hash);
364        }
365    }
366
367    private void deleteFile(String name) {
368        if (DEBUG) Slog.e(TAG, "Delete file " + name);
369        synchronized (mFileWriteLock) {
370            File file = new File(name);
371            if (file.exists()) {
372                file.delete();
373                mCache.putFile(name, null);
374            }
375        }
376    }
377
378    public void writeCredentialHash(CredentialHash hash, int userId) {
379        byte[] patternHash = null;
380        byte[] passwordHash = null;
381
382        if (hash.type == LockPatternUtils.CREDENTIAL_TYPE_PASSWORD) {
383            passwordHash = hash.hash;
384        } else if (hash.type == LockPatternUtils.CREDENTIAL_TYPE_PATTERN) {
385            patternHash = hash.hash;
386        }
387        writeFile(getLockPasswordFilename(userId), passwordHash);
388        writeFile(getLockPatternFilename(userId), patternHash);
389    }
390
391    @VisibleForTesting
392    String getLockPatternFilename(int userId) {
393        return getLockCredentialFilePathForUser(userId, LOCK_PATTERN_FILE);
394    }
395
396    @VisibleForTesting
397    String getLockPasswordFilename(int userId) {
398        return getLockCredentialFilePathForUser(userId, LOCK_PASSWORD_FILE);
399    }
400
401    @VisibleForTesting
402    String getLegacyLockPatternFilename(int userId) {
403        return getLockCredentialFilePathForUser(userId, LEGACY_LOCK_PATTERN_FILE);
404    }
405
406    @VisibleForTesting
407    String getLegacyLockPasswordFilename(int userId) {
408        return getLockCredentialFilePathForUser(userId, LEGACY_LOCK_PASSWORD_FILE);
409    }
410
411    private String getBaseZeroLockPatternFilename(int userId) {
412        return getLockCredentialFilePathForUser(userId, BASE_ZERO_LOCK_PATTERN_FILE);
413    }
414
415    @VisibleForTesting
416    String getChildProfileLockFile(int userId) {
417        return getLockCredentialFilePathForUser(userId, CHILD_PROFILE_LOCK_FILE);
418    }
419
420    private String getLockCredentialFilePathForUser(int userId, String basename) {
421        String dataSystemDirectory = Environment.getDataDirectory().getAbsolutePath() +
422                        SYSTEM_DIRECTORY;
423        if (userId == 0) {
424            // Leave it in the same place for user 0
425            return dataSystemDirectory + basename;
426        } else {
427            return new File(Environment.getUserSystemDirectory(userId), basename).getAbsolutePath();
428        }
429    }
430
431    public void writeSyntheticPasswordState(int userId, long handle, String name, byte[] data) {
432        writeFile(getSynthenticPasswordStateFilePathForUser(userId, handle, name), data);
433    }
434
435    public byte[] readSyntheticPasswordState(int userId, long handle, String name) {
436        return readFile(getSynthenticPasswordStateFilePathForUser(userId, handle, name));
437    }
438
439    public void deleteSyntheticPasswordState(int userId, long handle, String name) {
440        String path = getSynthenticPasswordStateFilePathForUser(userId, handle, name);
441        File file = new File(path);
442        if (file.exists()) {
443            try {
444                mContext.getSystemService(StorageManager.class).secdiscard(file.getAbsolutePath());
445            } catch (Exception e) {
446                Slog.w(TAG, "Failed to secdiscard " + path, e);
447            } finally {
448                file.delete();
449            }
450            mCache.putFile(path, null);
451        }
452    }
453
454    @VisibleForTesting
455    protected File getSyntheticPasswordDirectoryForUser(int userId) {
456        return new File(Environment.getDataSystemDeDirectory(userId) ,SYNTHETIC_PASSWORD_DIRECTORY);
457    }
458
459    @VisibleForTesting
460    protected String getSynthenticPasswordStateFilePathForUser(int userId, long handle,
461            String name) {
462        File baseDir = getSyntheticPasswordDirectoryForUser(userId);
463        String baseName = String.format("%016x.%s", handle, name);
464        if (!baseDir.exists()) {
465            baseDir.mkdir();
466        }
467        return new File(baseDir, baseName).getAbsolutePath();
468    }
469
470    public void removeUser(int userId) {
471        SQLiteDatabase db = mOpenHelper.getWritableDatabase();
472
473        final UserManager um = (UserManager) mContext.getSystemService(USER_SERVICE);
474        final UserInfo parentInfo = um.getProfileParent(userId);
475
476        if (parentInfo == null) {
477            // This user owns its lock settings files - safe to delete them
478            synchronized (mFileWriteLock) {
479                String name = getLockPasswordFilename(userId);
480                File file = new File(name);
481                if (file.exists()) {
482                    file.delete();
483                    mCache.putFile(name, null);
484                }
485                name = getLockPatternFilename(userId);
486                file = new File(name);
487                if (file.exists()) {
488                    file.delete();
489                    mCache.putFile(name, null);
490                }
491            }
492        } else {
493            // Managed profile
494            removeChildProfileLock(userId);
495        }
496
497        File spStateDir = getSyntheticPasswordDirectoryForUser(userId);
498        try {
499            db.beginTransaction();
500            db.delete(TABLE, COLUMN_USERID + "='" + userId + "'", null);
501            db.setTransactionSuccessful();
502            mCache.removeUser(userId);
503            // The directory itself will be deleted as part of user deletion operation by the
504            // framework, so only need to purge cache here.
505            //TODO: (b/34600579) invoke secdiscardable
506            mCache.purgePath(spStateDir.getAbsolutePath());
507        } finally {
508            db.endTransaction();
509        }
510    }
511
512    @VisibleForTesting
513    void closeDatabase() {
514        mOpenHelper.close();
515    }
516
517    @VisibleForTesting
518    void clearCache() {
519        mCache.clear();
520    }
521
522    public interface Callback {
523        void initialize(SQLiteDatabase db);
524    }
525
526    class DatabaseHelper extends SQLiteOpenHelper {
527        private static final String TAG = "LockSettingsDB";
528        private static final String DATABASE_NAME = "locksettings.db";
529
530        private static final int DATABASE_VERSION = 2;
531
532        private Callback mCallback;
533
534        public DatabaseHelper(Context context) {
535            super(context, DATABASE_NAME, null, DATABASE_VERSION);
536            setWriteAheadLoggingEnabled(true);
537        }
538
539        public void setCallback(Callback callback) {
540            mCallback = callback;
541        }
542
543        private void createTable(SQLiteDatabase db) {
544            db.execSQL("CREATE TABLE " + TABLE + " (" +
545                    "_id INTEGER PRIMARY KEY AUTOINCREMENT," +
546                    COLUMN_KEY + " TEXT," +
547                    COLUMN_USERID + " INTEGER," +
548                    COLUMN_VALUE + " TEXT" +
549                    ");");
550        }
551
552        @Override
553        public void onCreate(SQLiteDatabase db) {
554            createTable(db);
555            if (mCallback != null) {
556                mCallback.initialize(db);
557            }
558        }
559
560        @Override
561        public void onUpgrade(SQLiteDatabase db, int oldVersion, int currentVersion) {
562            int upgradeVersion = oldVersion;
563            if (upgradeVersion == 1) {
564                // Previously migrated lock screen widget settings. Now defunct.
565                upgradeVersion = 2;
566            }
567
568            if (upgradeVersion != DATABASE_VERSION) {
569                Log.w(TAG, "Failed to upgrade database!");
570            }
571        }
572    }
573
574    /**
575     * Cache consistency model:
576     * - Writes to storage write directly to the cache, but this MUST happen within the atomic
577     *   section either provided by the database transaction or mWriteLock, such that writes to the
578     *   cache and writes to the backing storage are guaranteed to occur in the same order
579     *
580     * - Reads can populate the cache, but because they are no strong ordering guarantees with
581     *   respect to writes this precaution is taken:
582     *   - The cache is assigned a version number that increases every time the cache is modified.
583     *     Reads from backing storage can only populate the cache if the backing storage
584     *     has not changed since the load operation has begun.
585     *     This guarantees that no read operation can shadow a write to the cache that happens
586     *     after it had begun.
587     */
588    private static class Cache {
589        private final ArrayMap<CacheKey, Object> mCache = new ArrayMap<>();
590        private final CacheKey mCacheKey = new CacheKey();
591        private int mVersion = 0;
592
593        String peekKeyValue(String key, String defaultValue, int userId) {
594            Object cached = peek(CacheKey.TYPE_KEY_VALUE, key, userId);
595            return cached == DEFAULT ? defaultValue : (String) cached;
596        }
597
598        boolean hasKeyValue(String key, int userId) {
599            return contains(CacheKey.TYPE_KEY_VALUE, key, userId);
600        }
601
602        void putKeyValue(String key, String value, int userId) {
603            put(CacheKey.TYPE_KEY_VALUE, key, value, userId);
604        }
605
606        void putKeyValueIfUnchanged(String key, Object value, int userId, int version) {
607            putIfUnchanged(CacheKey.TYPE_KEY_VALUE, key, value, userId, version);
608        }
609
610        byte[] peekFile(String fileName) {
611            return (byte[]) peek(CacheKey.TYPE_FILE, fileName, -1 /* userId */);
612        }
613
614        boolean hasFile(String fileName) {
615            return contains(CacheKey.TYPE_FILE, fileName, -1 /* userId */);
616        }
617
618        void putFile(String key, byte[] value) {
619            put(CacheKey.TYPE_FILE, key, value, -1 /* userId */);
620        }
621
622        void putFileIfUnchanged(String key, byte[] value, int version) {
623            putIfUnchanged(CacheKey.TYPE_FILE, key, value, -1 /* userId */, version);
624        }
625
626        void setFetched(int userId) {
627            put(CacheKey.TYPE_FETCHED, "isFetched", "true", userId);
628        }
629
630        boolean isFetched(int userId) {
631            return contains(CacheKey.TYPE_FETCHED, "", userId);
632        }
633
634
635        private synchronized void put(int type, String key, Object value, int userId) {
636            // Create a new CachKey here because it may be saved in the map if the key is absent.
637            mCache.put(new CacheKey().set(type, key, userId), value);
638            mVersion++;
639        }
640
641        private synchronized void putIfUnchanged(int type, String key, Object value, int userId,
642                int version) {
643            if (!contains(type, key, userId) && mVersion == version) {
644                put(type, key, value, userId);
645            }
646        }
647
648        private synchronized boolean contains(int type, String key, int userId) {
649            return mCache.containsKey(mCacheKey.set(type, key, userId));
650        }
651
652        private synchronized Object peek(int type, String key, int userId) {
653            return mCache.get(mCacheKey.set(type, key, userId));
654        }
655
656        private synchronized int getVersion() {
657            return mVersion;
658        }
659
660        synchronized void removeUser(int userId) {
661            for (int i = mCache.size() - 1; i >= 0; i--) {
662                if (mCache.keyAt(i).userId == userId) {
663                    mCache.removeAt(i);
664                }
665            }
666
667            // Make sure in-flight loads can't write to cache.
668            mVersion++;
669        }
670
671        synchronized void purgePath(String path) {
672            for (int i = mCache.size() - 1; i >= 0; i--) {
673                CacheKey entry = mCache.keyAt(i);
674                if (entry.type == CacheKey.TYPE_FILE && entry.key.startsWith(path)) {
675                    mCache.removeAt(i);
676                }
677            }
678            mVersion++;
679        }
680
681        synchronized void clear() {
682            mCache.clear();
683            mVersion++;
684        }
685
686        private static final class CacheKey {
687            static final int TYPE_KEY_VALUE = 0;
688            static final int TYPE_FILE = 1;
689            static final int TYPE_FETCHED = 2;
690
691            String key;
692            int userId;
693            int type;
694
695            public CacheKey set(int type, String key, int userId) {
696                this.type = type;
697                this.key = key;
698                this.userId = userId;
699                return this;
700            }
701
702            @Override
703            public boolean equals(Object obj) {
704                if (!(obj instanceof CacheKey))
705                    return false;
706                CacheKey o = (CacheKey) obj;
707                return userId == o.userId && type == o.type && key.equals(o.key);
708            }
709
710            @Override
711            public int hashCode() {
712                return key.hashCode() ^ userId ^ type;
713            }
714        }
715    }
716
717}
718