Source code
Revision control
Copy as Markdown
Other Tools
Test Info: Warnings
- This test gets skipped with pattern: tsan
- Manifest: security/lockstore/tests/unit/xpcshell.toml
/* Any copyright is dedicated to the Public Domain.
"use strict";
const PW = "correct horse battery staple";
const PW2 = "rotated_password_42";
const PW_WRONG = "Tr0ub4dor&3";
// Per-test kek_refs minted in `add_setup` and reused across the file.
// Every kek_ref is of the form `lockstore::kek::<type>:<base64url(id)>`;
// tests that need a *fresh* kek_ref for isolation should call
// `mintLocalKek()` / `mintPasswordKek()` directly.
let KEK_LOCAL = null;
let KEK_PASSWORD = null;
function getService() {
return Cc["@mozilla.org/security/lockstore;1"].getService(Ci.nsILockstore);
}
function bytes(input) {
return new TextEncoder().encode(input);
}
function str(arr) {
return new TextDecoder().decode(new Uint8Array(arr));
}
async function mintLocalKek(identifier = "") {
return getService().createKek(
"local",
identifier,
"",
/* cacheTimeoutMs */ 0
);
}
async function mintPasswordKek(password) {
return getService().createKek(
"password",
/* identifier */ "",
password,
/* cacheTimeoutMs */ 0
);
}
// `NS_ERROR_INVALID_ARG` and `NS_ERROR_ILLEGAL_VALUE` are the same
// underlying code (0x80070057); the JS error message uses the latter.
const INVALID_ARG_RE = /NS_ERROR_(INVALID_ARG|ILLEGAL_VALUE)/;
add_setup(async function () {
do_get_profile();
KEK_LOCAL = await mintLocalKek();
// Single Password kek_ref reused across tests that need a non-Local
// KEK. Minted with the canonical test password `PW`; tests unlock it
// on demand via `ls.unlockKek(KEK_PASSWORD, PW, ...)`.
KEK_PASSWORD = await mintPasswordKek(PW);
// Keepalive collection: remove_kek / delete_dek now orphan-clean
// empty KEK records, so without something always wrapping under
// KEK_PASSWORD a later test would delete the record and break
// every subsequent test that relies on it. A keepalive coll wrapped
// under both KEKs sidesteps the cleanup.
const ls = getService();
await ls.unlockKek(KEK_PASSWORD, PW, /*timeoutMs*/ 60_000);
await ls.createDek("_keepalive", KEK_LOCAL, false);
await ls.addKek("_keepalive", KEK_LOCAL, KEK_PASSWORD);
await ls.lockKek(KEK_PASSWORD);
});
add_task(function test_service_accessible() {
const ls = getService();
Assert.ok(ls, "nsILockstore service must be obtainable");
// A freshly-minted Password kek_ref starts locked: createKek persists
// the wrapped KEK but does not populate the unlock cache.
Assert.ok(
!ls.isKekUnlocked(KEK_PASSWORD),
"fresh Password kek starts locked"
);
});
add_task(async function test_local_key_encrypt_decrypt_roundtrip() {
const ls = getService();
await ls.createDek("rt", KEK_LOCAL, false);
const plaintext = bytes("hello, lockstore");
const ct = await ls.encrypt("rt", KEK_LOCAL, plaintext);
Assert.greater(
ct.length,
plaintext.length,
"ciphertext longer than plaintext"
);
const round = await ls.decrypt("rt", KEK_LOCAL, ct);
Assert.equal(str(round), "hello, lockstore", "roundtrip equal");
});
add_task(async function test_create_dek_duplicate_rejects() {
const ls = getService();
// "rt" already exists from the previous task; recreating must fail.
await Assert.rejects(
ls.createDek("rt", KEK_LOCAL, false),
/NS_ERROR_FAILURE/,
"createDek on an existing collection rejects"
);
});
add_task(async function test_create_dek_empty_args_rejected() {
const ls = getService();
await Assert.rejects(
ls.createDek("", KEK_LOCAL, false),
INVALID_ARG_RE,
"createDek with empty collection rejects"
);
await Assert.rejects(
ls.createDek("col", "", false),
INVALID_ARG_RE,
"createDek with empty kekRef rejects"
);
});
add_task(async function test_encrypt_yields_unique_ciphertexts() {
const ls = getService();
// Same plaintext, same DEK, two encrypt calls — outputs MUST differ.
// The cipher uses a random nonce; identical ciphertexts would be a
// catastrophic invariant violation.
const pt = bytes("repeatable input");
const a = await ls.encrypt("rt", KEK_LOCAL, pt);
const b = await ls.encrypt("rt", KEK_LOCAL, pt);
Assert.equal(a.length, b.length, "same plaintext → same ciphertext length");
let differs = false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
differs = true;
break;
}
}
Assert.ok(differs, "two encryptions of the same plaintext must differ");
// Both round-trip to the same plaintext.
Assert.equal(str(await ls.decrypt("rt", KEK_LOCAL, a)), "repeatable input");
Assert.equal(str(await ls.decrypt("rt", KEK_LOCAL, b)), "repeatable input");
});
add_task(async function test_encrypt_empty_args_rejected() {
const ls = getService();
await Assert.rejects(
ls.encrypt("", KEK_LOCAL, bytes("x")),
INVALID_ARG_RE,
"encrypt with empty collection rejects"
);
await Assert.rejects(
ls.encrypt("rt", "", bytes("x")),
INVALID_ARG_RE,
"encrypt with empty kekRef rejects"
);
});
add_task(async function test_encrypt_no_dek_rejected() {
const ls = getService();
await Assert.rejects(
ls.encrypt("never-created", KEK_LOCAL, bytes("x")),
/NS_ERROR_NOT_AVAILABLE/,
"encrypt against a collection without a DEK rejects"
);
});
add_task(async function test_decrypt_empty_args_rejected() {
const ls = getService();
const ct = await ls.encrypt("rt", KEK_LOCAL, bytes("x"));
await Assert.rejects(
ls.decrypt("", KEK_LOCAL, ct),
INVALID_ARG_RE,
"decrypt with empty collection rejects"
);
await Assert.rejects(
ls.decrypt("rt", "", ct),
INVALID_ARG_RE,
"decrypt with empty kekRef rejects"
);
await Assert.rejects(
ls.decrypt("rt", KEK_LOCAL, []),
INVALID_ARG_RE,
"decrypt of empty ciphertext rejects"
);
});
add_task(async function test_decrypt_no_dek_rejected() {
const ls = getService();
// Build a syntactically plausible ciphertext (>0 bytes) under a
// collection that doesn't exist; FFI must reject NotAvailable.
await Assert.rejects(
ls.decrypt("never-created", KEK_LOCAL, [1, 2, 3, 4, 5, 6, 7, 8, 9]),
/NS_ERROR_NOT_AVAILABLE/,
"decrypt against a collection without a DEK rejects"
);
});
add_task(async function test_decrypt_corrupted_ciphertext_rejects() {
const ls = getService();
const ct = await ls.encrypt("rt", KEK_LOCAL, bytes("tamper-me"));
// Flip a bit somewhere inside the AEAD-protected region (skip the
// cipher-suite tag at index 0 to avoid the early-out that triggers
// INVALID_ARG instead of an AEAD failure).
const tampered = new Uint8Array(ct);
tampered[Math.floor(tampered.length / 2)] ^= 0x01;
await Assert.rejects(
ls.decrypt("rt", KEK_LOCAL, Array.from(tampered)),
/NS_ERROR_/,
"tampering with ciphertext bytes must reject"
);
});
add_task(async function test_decrypt_truncated_ciphertext_rejects() {
const ls = getService();
const ct = await ls.encrypt("rt", KEK_LOCAL, bytes("truncate-me"));
// Drop the trailing AEAD tag → decryption must reject.
const truncated = ct.slice(0, ct.length - 4);
await Assert.rejects(
ls.decrypt("rt", KEK_LOCAL, Array.from(truncated)),
/NS_ERROR_/,
"truncated ciphertext must reject"
);
});
add_task(async function test_decrypt_with_wrong_kek_rejects() {
const ls = getService();
// Create a collection wrapped only under KEK_LOCAL.
await ls.createDek("local-only", KEK_LOCAL, false);
const ct = await ls.encrypt("local-only", KEK_LOCAL, bytes("wrong-kek"));
// Decrypting under a KEK that doesn't wrap this DEK must reject. PP
// is not unlocked here either, but the failure mode we care about is
// "this KEK doesn't wrap this collection".
await Assert.rejects(
ls.decrypt("local-only", KEK_PASSWORD, ct),
/NS_ERROR_NOT_AVAILABLE/,
"decrypt under a KEK that does not wrap the collection rejects"
);
// Cleanup so listDeks in later tests stays bounded.
await ls.deleteDek("local-only");
});
add_task(async function test_list_deks_and_delete() {
const ls = getService();
await ls.createDek("one", KEK_LOCAL, false);
await ls.createDek("two", KEK_LOCAL, false);
const before = await ls.listDeks();
Assert.ok(before.includes("one"));
Assert.ok(before.includes("two"));
await ls.deleteDek("one");
const after = await ls.listDeks();
Assert.ok(!after.includes("one"), "deleted collection disappears");
Assert.ok(after.includes("two"), "other collection remains");
// Second delete rejects with NotAvailable.
await Assert.rejects(
ls.deleteDek("one"),
/NS_ERROR_NOT_AVAILABLE/,
"second deleteDek rejects"
);
await Assert.rejects(
ls.deleteDek("never-existed"),
/NS_ERROR_NOT_AVAILABLE/,
"deleteDek on missing collection rejects"
);
});
add_task(async function test_delete_dek_empty_arg_rejected() {
const ls = getService();
await Assert.rejects(
ls.deleteDek(""),
INVALID_ARG_RE,
"deleteDek with empty arg rejects"
);
});
add_task(async function test_listKeks_round_trip() {
const ls = getService();
await ls.createDek("keks-rt", KEK_LOCAL, false);
let refs = await ls.listKeks("keks-rt");
Assert.deepEqual(
refs,
[KEK_LOCAL],
"listKeks reports only KEK_LOCAL after createDek"
);
// addKek under Password would require unlock setup; use the local
// KEK twice as a no-op self-test would fail. Instead, exercise the
// unknown-collection rejection path here and the addKek/removeKek
// listing changes are pinned in the gtest where we can use the
// synthetic test KEK level. In production the round-trip is exercised
// end-to-end via lockstore-SDR's Password upgrade.
await Assert.rejects(
ls.listKeks("never-created"),
/NS_ERROR_NOT_AVAILABLE/,
"listKeks against an unknown collection rejects NotAvailable"
);
await Assert.rejects(
ls.listKeks(""),
/NS_ERROR_NOT_AVAILABLE/,
"listKeks with empty arg rejects with NotAvailable (collection lookup fails)"
);
await ls.deleteDek("keks-rt");
});
add_task(async function test_delete_dek_nonexistent_rejects() {
const ls = getService();
await Assert.rejects(
ls.deleteDek("never-existed-coll"),
/NS_ERROR_NOT_AVAILABLE/,
"deleteDek on a missing collection rejects"
);
});
add_task(async function test_delete_dek_succeeds() {
const ls = getService();
await ls.createDek("safe-delete", KEK_LOCAL, false);
await ls.deleteDek("safe-delete");
await Assert.rejects(
ls.encrypt("safe-delete", KEK_LOCAL, bytes("nope")),
/NS_ERROR_NOT_AVAILABLE/,
"DEK is gone after deleteDek"
);
});
add_task(async function test_delete_dek_nonexistent_rejects() {
const ls = getService();
await Assert.rejects(
ls.deleteDek("never-existed-coll-2"),
/NS_ERROR_NOT_AVAILABLE/,
"deleteDek on a missing collection rejects"
);
});
add_task(async function test_delete_dek_empty_arg_rejected() {
const ls = getService();
await Assert.rejects(
ls.deleteDek(""),
INVALID_ARG_RE,
"deleteDek with empty arg rejects"
);
});
add_task(async function test_password_lifecycle() {
const ls = getService();
// NOTE: production iterations are 800 000; expect ~1s per
// unlockKek call on modern hardware. The unlock runs off-main-thread.
Assert.ok(!ls.isKekUnlocked(KEK_PASSWORD), "freshly minted, starts locked");
// Wrong password rejected; cache stays empty.
await Assert.rejects(
ls.unlockKek(KEK_PASSWORD, PW_WRONG, 60000),
/NS_ERROR_ABORT/,
"wrong password rejected"
);
Assert.ok(
!ls.isKekUnlocked(KEK_PASSWORD),
"wrong password does not populate cache"
);
await ls.unlockKek(KEK_PASSWORD, PW, 60000);
Assert.ok(ls.isKekUnlocked(KEK_PASSWORD), "correct password unlocks");
// Encrypt/decrypt under the Password KEK while unlocked.
await ls.createDek("pw-col", KEK_PASSWORD, false);
const ct = await ls.encrypt("pw-col", KEK_PASSWORD, bytes("secret"));
const round = await ls.decrypt("pw-col", KEK_PASSWORD, ct);
Assert.equal(str(round), "secret", "Password roundtrip while unlocked");
// Locking invalidates the cache; subsequent encrypt rejects.
await ls.lockKek(KEK_PASSWORD);
Assert.ok(!ls.isKekUnlocked(KEK_PASSWORD));
await Assert.rejects(
ls.encrypt("pw-col", KEK_PASSWORD, bytes("nope")),
/NS_ERROR_NOT_AVAILABLE/,
"encrypt under locked Password kek rejects with Locked"
);
await ls.deleteDek("pw-col");
});
add_task(async function test_multiple_password_keks_unlock_independently() {
const ls = getService();
// Two independent password KEKs minted with different passwords.
const kekA = await mintPasswordKek(PW);
const kekB = await mintPasswordKek(PW2);
Assert.notEqual(kekA, kekB, "createKek mints distinct kek_refs each call");
await ls.unlockKek(kekA, PW, 60000);
Assert.ok(ls.isKekUnlocked(kekA));
Assert.ok(!ls.isKekUnlocked(kekB), "kek B is unaffected by kek A's unlock");
// PW2 is correct for kekB but wrong for kekA.
await Assert.rejects(
ls.unlockKek(kekA, PW2, 60000),
/NS_ERROR_ABORT/,
"PW2 is wrong for kekA"
);
await ls.unlockKek(kekB, PW2, 60000);
Assert.ok(ls.isKekUnlocked(kekB));
// lockKek scopes per-kek_ref.
await ls.lockKek(kekA);
Assert.ok(!ls.isKekUnlocked(kekA));
Assert.ok(ls.isKekUnlocked(kekB), "kek B still unlocked after kek A locked");
await ls.lockKek(kekB);
});
add_task(async function test_add_remove_kek() {
const ls = getService();
// Already unlocked from prior task; re-unlock defensively.
if (!ls.isKekUnlocked(KEK_PASSWORD)) {
await ls.unlockKek(KEK_PASSWORD, PW, 60000);
}
await ls.createDek("multi", KEK_LOCAL, true);
await ls.addKek("multi", KEK_LOCAL, KEK_PASSWORD);
const ct = await ls.encrypt("multi", KEK_LOCAL, bytes("shared DEK"));
const round = await ls.decrypt("multi", KEK_PASSWORD, ct);
Assert.equal(str(round), "shared DEK", "same DEK decrypts via either KEK");
await ls.removeKek("multi", KEK_PASSWORD);
await Assert.rejects(
ls.encrypt("multi", KEK_PASSWORD, bytes("nope")),
/NS_ERROR_NOT_AVAILABLE/,
"removed KEK is gone"
);
});
add_task(async function test_remove_last_kek_rejected() {
const ls = getService();
// "multi" is now wrapped only under KEK_LOCAL (PP wrapping was
// removed in the previous task). Removing the last wrapping must
// fail to avoid silently orphaning the DEK.
await Assert.rejects(
ls.removeKek("multi", KEK_LOCAL),
/NS_ERROR_/,
"removing the last remaining wrapping must reject"
);
// Sanity: collection still usable.
const ct = await ls.encrypt("multi", KEK_LOCAL, bytes("still here"));
Assert.equal(
str(await ls.decrypt("multi", KEK_LOCAL, ct)),
"still here",
"DEK unchanged after rejected remove"
);
});
add_task(async function test_remove_kek_not_present_rejects() {
const ls = getService();
// KEK_PASSWORD doesn't wrap "multi" anymore; removing a non-present
// wrapping must reject (vs. silently no-op).
await Assert.rejects(
ls.removeKek("multi", KEK_PASSWORD),
/NS_ERROR_/,
"removeKek of a kekRef that is not currently wrapping rejects"
);
});
add_task(async function test_add_kek_missing_collection_rejects() {
const ls = getService();
if (!ls.isKekUnlocked(KEK_PASSWORD)) {
await ls.unlockKek(KEK_PASSWORD, PW, 60000);
}
await Assert.rejects(
ls.addKek("never-created-coll", KEK_LOCAL, KEK_PASSWORD),
/NS_ERROR_NOT_AVAILABLE/,
"addKek against a collection without a DEK rejects"
);
});
add_task(async function test_add_kek_empty_args_rejected() {
const ls = getService();
await Assert.rejects(
ls.addKek("", KEK_LOCAL, KEK_PASSWORD),
INVALID_ARG_RE,
"addKek with empty collection rejects"
);
await Assert.rejects(
ls.addKek("multi", "", KEK_PASSWORD),
INVALID_ARG_RE,
"addKek with empty fromKekRef rejects"
);
await Assert.rejects(
ls.addKek("multi", KEK_LOCAL, ""),
INVALID_ARG_RE,
"addKek with empty toKekRef rejects"
);
});
add_task(async function test_remove_kek_empty_args_rejected() {
const ls = getService();
await Assert.rejects(
ls.removeKek("", KEK_LOCAL),
INVALID_ARG_RE,
"removeKek with empty collection rejects"
);
await Assert.rejects(
ls.removeKek("multi", ""),
INVALID_ARG_RE,
"removeKek with empty kekRef rejects"
);
});
add_task(async function test_local_key_is_always_unlocked() {
const ls = getService();
Assert.ok(ls.isKekUnlocked(KEK_LOCAL), "LocalKey always unlocked");
await ls.lockKek(KEK_LOCAL); // no-op
Assert.ok(ls.isKekUnlocked(KEK_LOCAL), "LocalKey still unlocked after lock");
await ls.unlockKek(KEK_LOCAL, "", 60000); // no-op
Assert.ok(ls.isKekUnlocked(KEK_LOCAL));
});
add_task(async function test_lock_all() {
const ls = getService();
if (!ls.isKekUnlocked(KEK_PASSWORD)) {
await ls.unlockKek(KEK_PASSWORD, PW, 60000);
}
Assert.ok(ls.isKekUnlocked(KEK_PASSWORD), "PP unlocked before lock()");
await ls.lock();
Assert.ok(!ls.isKekUnlocked(KEK_PASSWORD), "PP locked after lock()");
Assert.ok(ls.isKekUnlocked(KEK_LOCAL), "LocalKey unaffected by lock()");
});
add_task(async function test_unknown_kek_ref_rejected() {
const ls = getService();
const BOGUS = "lockstore::kek::bogus";
// Every kek_ref-taking method validates the prefix; a malformed
// kek_ref surfaces as InvalidKekRef (NS_ERROR_INVALID_ARG).
Assert.throws(
() => ls.isKekUnlocked(BOGUS),
INVALID_ARG_RE,
"isKekUnlocked rejects an unknown kek_ref"
);
await Assert.rejects(
ls.unlockKek(BOGUS, "whatever", 60000),
INVALID_ARG_RE,
"unlockKek rejects an unknown kek_ref"
);
await Assert.rejects(
ls.lockKek(BOGUS),
INVALID_ARG_RE,
"lockKek rejects an unknown kek_ref"
);
});
add_task(async function test_empty_kek_ref_rejected() {
const ls = getService();
await Assert.rejects(
ls.unlockKek("", "pw", 60000),
INVALID_ARG_RE,
"empty kek_ref rejected by unlockKek"
);
await Assert.rejects(
ls.lockKek(""),
INVALID_ARG_RE,
"empty kek_ref rejected by lockKek"
);
Assert.throws(
() => ls.isKekUnlocked(""),
INVALID_ARG_RE,
"empty kek_ref rejected by isKekUnlocked"
);
});
add_task(async function test_pkcs11_unknown_kek_ref_rejected() {
// No PKCS#11 token is registered in the xpcshell harness, so we can
// only exercise error paths for the PKCS#11 branch. A kek_ref that
// has no persisted Pkcs11KekRecord must reject without touching any
// slot.
const ls = getService();
const bogusPkcs11 = "lockstore::kek::pkcs11:not-a-real-record";
await Assert.rejects(
ls.unlockKek(bogusPkcs11, "pin-bytes", 60000),
/NS_ERROR_(NOT_AVAILABLE|INVALID_ARG|ILLEGAL_VALUE|FAILURE)/,
"unknown PKCS#11 kek_ref rejected"
);
});
// The service serialises FFI on a private queue. Multiple in-flight
// async ops must all complete and produce distinct ciphertexts.
add_task(async function test_concurrent_encrypts_serialised() {
const ls = getService();
await ls.createDek("concurrent", KEK_LOCAL, false);
const N = 8;
const pt = bytes("parallel-but-serialised");
const cts = await Promise.all(
Array.from({ length: N }, () => ls.encrypt("concurrent", KEK_LOCAL, pt))
);
Assert.equal(cts.length, N, "every concurrent encrypt resolved");
// All ciphertexts of the same plaintext must be unique (random nonce).
const seen = new Set();
for (const ct of cts) {
seen.add(Array.from(ct).join(","));
}
Assert.equal(
seen.size,
N,
"every concurrent encrypt produced a unique ciphertext"
);
// All decrypt back to the original plaintext.
const rounds = await Promise.all(
cts.map(ct => ls.decrypt("concurrent", KEK_LOCAL, ct))
);
for (const r of rounds) {
Assert.equal(str(r), "parallel-but-serialised");
}
await ls.deleteDek("concurrent");
});
add_task(async function test_concurrent_mixed_ops() {
const ls = getService();
// Mix createDek / encrypt / listDeks / deleteDek in flight.
const colls = ["mix-a", "mix-b", "mix-c"];
await Promise.all(colls.map(c => ls.createDek(c, KEK_LOCAL, false)));
const collsAfter = await ls.listDeks();
for (const c of colls) {
Assert.ok(collsAfter.includes(c), `${c} present after concurrent create`);
}
// Encrypt under each in parallel.
const cts = await Promise.all(
colls.map(c => ls.encrypt(c, KEK_LOCAL, bytes(c)))
);
// Decrypt under each in parallel.
const rounds = await Promise.all(
colls.map((c, i) => ls.decrypt(c, KEK_LOCAL, cts[i]))
);
for (let i = 0; i < colls.length; i++) {
Assert.equal(str(rounds[i]), colls[i], `${colls[i]} round-trips correctly`);
}
// Cleanup in parallel.
await Promise.all(colls.map(c => ls.deleteDek(c)));
const collsFinal = await ls.listDeks();
for (const c of colls) {
Assert.ok(!collsFinal.includes(c), `${c} cleaned up`);
}
});
add_task(async function test_import_dek_roundtrip() {
const ls = getService();
// 32 known bytes — pattern, not crypto-quality.
const dek = new Uint8Array(32);
for (let i = 0; i < dek.length; i++) {
dek[i] = (i + 1) & 0xff;
}
await ls.importDek("imported", KEK_LOCAL, dek, true);
const round = await ls.getDek("imported", KEK_LOCAL);
Assert.equal(round.length, 32, "exported DEK is 32 bytes");
for (let i = 0; i < 32; i++) {
Assert.equal(round[i], dek[i], `byte ${i} round-trips`);
}
// Sanity: encrypt/decrypt under the imported DEK.
const ct = await ls.encrypt("imported", KEK_LOCAL, bytes("payload"));
Assert.equal(str(await ls.decrypt("imported", KEK_LOCAL, ct)), "payload");
await ls.deleteDek("imported");
});
add_task(async function test_import_dek_wrong_length_rejected() {
const ls = getService();
const short = new Uint8Array(16);
await Assert.rejects(
ls.importDek("short", KEK_LOCAL, short, true),
/NS_ERROR_FAILURE/,
"importDek with non-32-byte input rejects (InvalidConfiguration)"
);
});
add_task(async function test_import_dek_empty_args_rejected() {
const ls = getService();
const dek = new Uint8Array(32);
await Assert.rejects(
ls.importDek("", KEK_LOCAL, dek, true),
INVALID_ARG_RE,
"importDek with empty collection rejects"
);
await Assert.rejects(
ls.importDek("c", "", dek, true),
INVALID_ARG_RE,
"importDek with empty kekRef rejects"
);
await Assert.rejects(
ls.importDek("c", KEK_LOCAL, [], true),
INVALID_ARG_RE,
"importDek with empty dekBytes rejects"
);
});
add_task(async function test_import_dek_duplicate_rejected() {
const ls = getService();
const dek = new Uint8Array(32);
await ls.importDek("dup", KEK_LOCAL, dek, true);
await Assert.rejects(
ls.importDek("dup", KEK_LOCAL, dek, true),
/NS_ERROR_FAILURE/,
"importDek on an existing collection rejects"
);
await ls.deleteDek("dup");
});
add_task(async function test_is_dek_extractable_true() {
const ls = getService();
await ls.createDek("extract-yes", KEK_LOCAL, true);
Assert.equal(
await ls.isDekExtractable("extract-yes"),
true,
"isDekExtractable reflects extractable=true at creation"
);
await ls.deleteDek("extract-yes");
});
add_task(async function test_is_dek_extractable_false() {
const ls = getService();
await ls.createDek("extract-no", KEK_LOCAL, false);
Assert.equal(
await ls.isDekExtractable("extract-no"),
false,
"isDekExtractable reflects extractable=false at creation"
);
await ls.deleteDek("extract-no");
});
add_task(async function test_is_dek_extractable_missing_rejected() {
const ls = getService();
await Assert.rejects(
ls.isDekExtractable("never-existed-coll"),
/NS_ERROR_NOT_AVAILABLE/,
"isDekExtractable on a missing collection rejects"
);
});
add_task(async function test_switch_kek_round_trip() {
const ls = getService();
if (!ls.isKekUnlocked(KEK_PASSWORD)) {
await ls.unlockKek(KEK_PASSWORD, PW, 60000);
}
await ls.createDek("switch-rt", KEK_LOCAL, false);
// Produce ciphertext under the OLD kek_ref; after switching to PP the
// same ciphertext must still decrypt — switch_kek changes only the
// wrapping, not the DEK bytes.
const ct = await ls.encrypt("switch-rt", KEK_LOCAL, bytes("preserved"));
await ls.switchKek("switch-rt", KEK_LOCAL, KEK_PASSWORD);
const refs = await ls.listKeks("switch-rt");
Assert.deepEqual(
refs,
[KEK_PASSWORD],
"only the new kekRef wraps the collection after switch"
);
Assert.equal(
str(await ls.decrypt("switch-rt", KEK_PASSWORD, ct)),
"preserved",
"ciphertext decrypts under the new kekRef (DEK bytes preserved)"
);
await Assert.rejects(
ls.decrypt("switch-rt", KEK_LOCAL, ct),
/NS_ERROR_NOT_AVAILABLE/,
"old kekRef no longer wraps the collection"
);
await ls.deleteDek("switch-rt");
});
add_task(async function test_switch_kek_same_ref_rejected() {
const ls = getService();
await ls.createDek("same-ref", KEK_LOCAL, false);
await Assert.rejects(
ls.switchKek("same-ref", KEK_LOCAL, KEK_LOCAL),
/NS_ERROR_FAILURE/,
"switchKek(old == new) rejects as InvalidConfiguration"
);
await ls.deleteDek("same-ref");
});
add_task(async function test_switch_kek_empty_args_rejected() {
const ls = getService();
await Assert.rejects(
ls.switchKek("", KEK_LOCAL, KEK_PASSWORD),
INVALID_ARG_RE,
"switchKek with empty collection rejects"
);
await Assert.rejects(
ls.switchKek("c", "", KEK_PASSWORD),
INVALID_ARG_RE,
"switchKek with empty oldKekRef rejects"
);
await Assert.rejects(
ls.switchKek("c", KEK_LOCAL, ""),
INVALID_ARG_RE,
"switchKek with empty newKekRef rejects"
);
});
add_task(async function test_switch_kek_missing_collection_rejected() {
const ls = getService();
await Assert.rejects(
ls.switchKek("never-existed", KEK_LOCAL, KEK_PASSWORD),
/NS_ERROR_NOT_AVAILABLE/,
"switchKek on a missing collection rejects"
);
});
// ---------------------------------------------------------------------------
// deleteKek
// ---------------------------------------------------------------------------
add_task(async function test_delete_kek_drops_unreferenced_local() {
const ls = getService();
const ephemeral = await mintLocalKek();
await ls.deleteKek(ephemeral);
await Assert.rejects(
ls.createDek("dk-after-delete", ephemeral, false),
/NS_ERROR/,
"createDek under a deleted local kek_ref fails"
);
});
add_task(async function test_delete_kek_drops_unreferenced_password() {
const ls = getService();
const ephemeral = await mintPasswordKek(PW);
await ls.deleteKek(ephemeral);
await Assert.rejects(
ls.unlockKek(ephemeral, PW, /*timeoutMs*/ 60_000),
/NS_ERROR/,
"unlockKek on a deleted password kek_ref fails"
);
});
add_task(async function test_delete_kek_rejects_when_in_use() {
const ls = getService();
const ephemeral = await mintLocalKek();
await ls.createDek("dk-in-use", ephemeral, false);
await Assert.rejects(
ls.deleteKek(ephemeral),
/NS_ERROR_FAILURE/,
"deleteKek of a kek_ref that still wraps a DEK is rejected"
);
});
add_task(async function test_delete_kek_unknown_ref_rejects() {
const ls = getService();
await Assert.rejects(
ls.deleteKek("lockstore::kek::local:AAAAAAAAAAAAAAAAAAAAAA"),
/NS_ERROR_NOT_AVAILABLE/,
"deleteKek on an unknown kek_ref rejects with NOT_AVAILABLE"
);
});
add_task(async function test_delete_kek_invalid_arg_rejected() {
const ls = getService();
await Assert.rejects(
ls.deleteKek(""),
INVALID_ARG_RE,
"deleteKek with empty kek_ref is rejected"
);
await Assert.rejects(
ls.deleteKek("not-a-valid-kek-ref"),
/NS_ERROR/,
"deleteKek with malformed kek_ref is rejected"
);
});