Skip to content
This repository has been archived by the owner on Mar 5, 2022. It is now read-only.

Commit

Permalink
feat(password): accept array of password objects for rotation
Browse files Browse the repository at this point in the history
This adds a way to provide array of passwords like this:

```js
import ironStore from "iron-store";

const store = await ironStore({
  password: [
    {
      id: 2,
      password: "generated_complex_password_at_least_32_characters_long",
    },
    {
      id: 1,
      password: "generated_complex_password_at_least_32_characters_long",
    },
  ],
});
store.set("user", { id: 80, admin: true });
const seal = await store.seal();
```

Required for vvo/iron-session#69
  • Loading branch information
vvo committed Apr 30, 2020
1 parent fb01b8a commit 5ce4fef
Show file tree
Hide file tree
Showing 4 changed files with 253 additions and 51 deletions.
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist/
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Signature and encryption is based on [@hapi/iron](https://hapi.dev/family/iron/)

Use https://1password.com/password-generator/ to generate strong passwords.

You can use multiple passwords (password rotation).

```bash
npm add iron-store
```
Expand Down Expand Up @@ -43,9 +45,40 @@ console.log(user);
// { id:80, admin:true }
```

**Creating a store using multiple passwords (_password rotation_)**:

You can implement password rotation by providing an array of passwords and ids. The id can be a string (letters, numbers and \_) or just a number.

The first password in the array is always the one used to `seal` data. All the other passwords are used to decrypt data.

Note: The `id` is mandatory and part of the seal, so that we can know in advance which password to use when decrypting. You need to use unique ids. You cannot reuse a id for a different password.

Note: If you started to use the `string` form of password, you can always move to an `array` of password objects. The `string` form of your password is internally handled as `{ id: 1, password }`.

```js
import ironStore from "iron-store";

const store = await ironStore({
password: [
{
id: 2,
password: "generated_complex_password_at_least_32_characters_long",
},
{
id: 1,
password: "generated_complex_password_at_least_32_characters_long",
},
],
});
store.set("user", { id: 80, admin: true });
const seal = await store.seal();
```

Th

## API

### ironStore({ sealed, password, ttl = 0 })
### ironStore({ [sealed], password, ttl = 0 })

### store.set(name, value)

Expand Down
47 changes: 42 additions & 5 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,22 @@ import Iron from "@hapi/iron";
import clone from "clone";

export default async function ironStore({ sealed, password, ttl = 0 }) {
if (typeof password !== "string" && !Array.isArray(password)) {
throw new Error(
"iron-store: bad `password` format, expected string or array of objects",
);
}

const store =
sealed !== undefined
? await Iron.unseal(sealed, password, {
...Iron.defaults,
ttl,
})
? await Iron.unseal(
sealed,
normalizePasswordForUnseal(sealed, password),
{
...Iron.defaults,
ttl,
},
)
: { persistent: {}, flash: {} };

return {
Expand Down Expand Up @@ -44,7 +54,34 @@ export default async function ironStore({ sealed, password, ttl = 0 }) {
store.flash = {};
},
seal() {
return Iron.seal(store, password, { ...Iron.defaults, ttl });
const passwordForSeal = Array.isArray(password)
? {
id: password[0].id,
secret: password[0].password,
}
: {
id: 1,
secret: password,
};
return Iron.seal(store, passwordForSeal, { ...Iron.defaults, ttl });
},
};
}

function normalizePasswordForUnseal(sealed, password) {
if (typeof password === "string") {
// sealed data comes from a previous version of iron-store (<= 1.2.0)
if (sealed.startsWith(`${Iron.macPrefix}**`)) {
return password;
}

return { 1: password };
}

return password.reduce((acc, currentPassword) => {
return {
[currentPassword.id]: currentPassword.password,
...acc,
};
}, {});
}
221 changes: 176 additions & 45 deletions lib/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ test("unset(name, value)", async () => {
const store = await ironStore({ password });
store.set("state", { id: 1 });
expect(store.get("state")).toMatchInlineSnapshot(`
Object {
"id": 1,
}
`);
Object {
"id": 1,
}
`);
expect(store.unset("state")).toMatchInlineSnapshot(`undefined`);
expect(store.get("state")).toMatchInlineSnapshot(`undefined`);
});
Expand All @@ -50,12 +50,12 @@ test("get()", async () => {
const store = await ironStore({ password });
store.set("user", { id: 2 });
expect(store.get()).toMatchInlineSnapshot(`
Object {
"user": Object {
"id": 2,
},
}
`);
Object {
"user": Object {
"id": 2,
},
}
`);
});

test("setFlash(name, value)", async () => {
Expand All @@ -70,42 +70,42 @@ test("it seals data", async () => {
store.set("user", { id: 3 });
const seal = await store.seal();
expect(typeof seal).toBe("string");
expect(seal.length).toBe(270); // we can't test the actual value are there's a random crypto/timestamp in it
expect(seal.length).toBe(271); // we can't test the actual value are there's a random crypto/timestamp in it
});

test("it reads sealed data", async () => {
// seal obtained from previous test using console.log
// seal obtained from previous test using console.log,
const sealed =
"Fe26.2**4e769b9b7b921621ed5658cfc0d7d8e267dc8ee93663c2803c257b31111394e3*jRXOJHmt_BDG9nNTXcVRXQ*UHpK9GYp7SXTiEsxTzTUq_tQD_-ZUp7PguEXy-bRFuBE4fW74-9wm9UtlWO2rlwB**d504d6d197d183efec0ae6d3c2378c43048c8752d6c3c591c92289ed01142b3c*3NG2fCo8A53CXPU8rEAMnDB7X9UkwzTaHieumPBqyTw";
"Fe26.2*1*1753454b45c36dc0f6600729a7be3c4460a227591631085f6a3c89765c08861a*K_i9AvjRlY79Vo999BMnJQ*jlfcl2VRtUNxAN2Wj-jrj3c26EppmxC4aQQzIuDLJhBWIdO8l1nGgF37l2iLqU7G**4063522d0f819c84e8ce439924a58358ef6883a0c7f7d03394a4e899161cbcb9*cEQvS7FdHFx74uyGEntKtuA6Vq2wY9PJ746qosLjams";
const store = await ironStore({ password, sealed });
expect(store.get("user")).toMatchInlineSnapshot(`
Object {
"id": 3,
}
`);
Object {
"id": 3,
}
`);
});

test("Data is cloned on set", async () => {
const store = await ironStore({ password });
const user = { id: 1200, admin: true };
store.set("user", user);
expect(store.get()).toMatchInlineSnapshot(`
Object {
"user": Object {
"admin": true,
"id": 1200,
},
}
`);
Object {
"user": Object {
"admin": true,
"id": 1200,
},
}
`);
user.id = 2200;
expect(store.get()).toMatchInlineSnapshot(`
Object {
"user": Object {
"admin": true,
"id": 1200,
},
}
`);
Object {
"user": Object {
"admin": true,
"id": 1200,
},
}
`);
});

test("Data is cloned on get", async () => {
Expand All @@ -115,27 +115,158 @@ test("Data is cloned on get", async () => {
const sessionUser = store.get("user");
sessionUser.id = 3400;
expect(store.get()).toMatchInlineSnapshot(`
Object {
"user": Object {
"admin": true,
"id": 1700,
},
}
`);
Object {
"user": Object {
"admin": true,
"id": 1700,
},
}
`);
});

test("store.clear()", async () => {
const store = await ironStore({ password });
const user = { id: 2000, admin: true };
store.set("user", user);
expect(store.get()).toMatchInlineSnapshot(`
Object {
"user": Object {
"admin": true,
"id": 2000,
},
}
`);
Object {
"user": Object {
"admin": true,
"id": 2000,
},
}
`);
store.clear();
expect(store.get()).toMatchInlineSnapshot(`Object {}`);
});

test("it allows for multiple passwords (password rotation)", async () => {
const firstPassword = [
{
id: 1,
password: "MJsZcjVkJKDoH8f35dAJNVWMbR8Z0cBr",
},
];
const secondPassword = [
{
id: 2,
password: "xgzqACsoFxwgg95DTkVi1wT0U0zfZu39",
},
{
id: 1,
password: "MJsZcjVkJKDoH8f35dAJNVWMbR8Z0cBr",
},
];

const firstStore = await ironStore({ password: firstPassword });
firstStore.set("user", { id: 200 });
const firstSeal = await firstStore.seal();

const secondStore = await ironStore({
sealed: firstSeal,
password: secondPassword,
});
expect(secondStore.get("user")).toMatchInlineSnapshot(`
Object {
"id": 200,
}
`);
});

test("it allows for multiple passwords, even when first password was a regular string", async () => {
const firstPassword = "QK317mtQky71D5MEd5BDPXNEEAPwAmnQ";
const secondPassword = [
{
id: 2,
password: "Eza3mXLurdtg91yUbAcCKbWK8nqwGhhW",
},
{
id: 1,
password: "QK317mtQky71D5MEd5BDPXNEEAPwAmnQ",
},
];

const firstStore = await ironStore({ password: firstPassword });
firstStore.set("user", { id: 220 });
const firstSeal = await firstStore.seal();

const secondStore = await ironStore({
sealed: firstSeal,
password: secondPassword,
});
expect(secondStore.get("user")).toMatchInlineSnapshot(`
Object {
"id": 220,
}
`);
});

test("it always uses first password from list for seal", async () => {
const firstPassword = [
{
id: 2,
password: "kimJoUsybVCw2hKZ3RN5j5FGjo33KiDt",
},
{
id: 1,
password: "voNwDrdCnmMNLNQBPUcE9mQcgBCfjYZu",
},
];
const secondPassword = [
{
id: 2,
password: "kimJoUsybVCw2hKZ3RN5j5FGjo33KiDt",
},
];

const firstStore = await ironStore({ password: firstPassword });
firstStore.set("user", { id: 240 });
const firstSeal = await firstStore.seal();

const secondStore = await ironStore({
sealed: firstSeal,
password: secondPassword,
});
expect(secondStore.get("user")).toMatchInlineSnapshot(`
Object {
"id": 240,
}
`);
});

test("it reads sealed data not containing password ids", async () => {
// seal obtained from a previous test using console.log,
// this seal does not contains a password id since it was created before
// password ids were automatically added by the library (<= 1.2.0)
// but it still works and is tested. Allowing for non breaking update
const sealed =
"Fe26.2**4e769b9b7b921621ed5658cfc0d7d8e267dc8ee93663c2803c257b31111394e3*jRXOJHmt_BDG9nNTXcVRXQ*UHpK9GYp7SXTiEsxTzTUq_tQD_-ZUp7PguEXy-bRFuBE4fW74-9wm9UtlWO2rlwB**d504d6d197d183efec0ae6d3c2378c43048c8752d6c3c591c92289ed01142b3c*3NG2fCo8A53CXPU8rEAMnDB7X9UkwzTaHieumPBqyTw";
const store = await ironStore({ password, sealed });
expect(store.get("user")).toMatchInlineSnapshot(`
Object {
"id": 3,
}
`);
});

test("it throws when trying to decrypt single passwords seals from iron-store <= 1.2.0 using multi passwords", async () => {
// This test the situation where we're trying to move from single password seals (<= 1.2.0) to
// multi passwords. There's no good way I can think of to do this well so we'll just throw instead
// Session libraries should re-create a session when this happens
// This is an edge case that won't exist for new users of the library or people that never move from old
// version single password to new version multi passwords
const sealed =
"Fe26.2**4e769b9b7b921621ed5658cfc0d7d8e267dc8ee93663c2803c257b31111394e3*jRXOJHmt_BDG9nNTXcVRXQ*UHpK9GYp7SXTiEsxTzTUq_tQD_-ZUp7PguEXy-bRFuBE4fW74-9wm9UtlWO2rlwB**d504d6d197d183efec0ae6d3c2378c43048c8752d6c3c591c92289ed01142b3c*3NG2fCo8A53CXPU8rEAMnDB7X9UkwzTaHieumPBqyTw";

await expect(async function () {
await ironStore({ password: [{ id: 1, password }], sealed });
}).rejects.toThrowErrorMatchingInlineSnapshot(`"Cannot find password: "`);
});

test("it throws on password bad format", async () => {
await expect(async function () {
await ironStore({ password: 12321 });
}).rejects.toThrowErrorMatchingInlineSnapshot(
`"iron-store: bad \`password\` format, expected string or array of objects"`,
);
});

0 comments on commit 5ce4fef

Please sign in to comment.