0.2.5 • Published 4 months ago

@ssc-half-light/identity v0.2.5

Weekly downloads
-
License
SEE LICENSE IN LI...
Repository
github
Last release
4 months ago

identity

tests Socket Badge module types license

This is an object representing a user. An Identity object contains a collection of "devices", where each device has several keypairs. This depends on each device having a keystore that stores the private keys.

We can do e2e encryption by creating a symmetric key, then encrypting that key to each device. So the symmetric key is encrypted with the public key of each device.

Each device has a primary keypair used for signing, which is did here, and also an "exchange" keypair, which is used for encrypting & decrypting things. In the Device record there is also an index aes, which is the symmetrical key that has been encrypted to the device's exchange key.

see also, keystore as used in crypto component

Devices are indexed by a sufficiently random key, created by calling createDeviceName with the primary did for the device.


E2E encryption

Sending a private message to an identity would mean encrypting a message with a new symmetric key, then encrypting n versions of the symmetric key, one for each device in the other identity.

You can think of it like one conversation = 1 symmetric key. The person initiating the conversation needs to know the exchange keys of the other party.


install

npm i -S @ssc-half-light/identity

demo

See a live demo of the example directory

This uses websockets to 'link' two devices. That is, a single AES key is encrypted to the exchange key on each device, so both devices are able to use the same key.


types

Identity

interface Identity {
    humanName:string,  /* a human readble name for the identity */
    username:string,  /* the random string for the root device.
      Not human-readable */
    rootDid:DID  /* `did:key:z${string}`
      The DID of the first device to use this identity */
    devices:Record<string, Device>  /* a map of devices in this identity */
}

Device

interface Device {
    name:string,  // the random string for this device
    did:DID,  // `did:key:z${string}`
    aes:string,  /* the symmetric key for this account, encrypted to the
      exchange key for this device */
    exchange:string  // public key used for encrypting & decrypting
}

group

A function from data to an encrypted string.

type Group = {
    groupMembers: Identity[];
    encryptedKeys: Record<string, string>;
    decrypt: (
        crypto:Implementation,
        group:Group,
        msg:string|Uint8Array
    ) => Promise<string>;
    (data:string|Uint8Array): Promise<string>
}

example

Start the example. This will start local servers and open a browser.

npm start

party

The example opens a websocket connection to our partykit server in response to DOM events. We generate a random 6 digit number, and use that to connect multiple devices to the same websocket server. The root device (the one that generated the PIN) will get a message from the new device, containing the exchange public key and DID. The root device then encrypts the AES key to the exchange key in the message, and then sends the encrypted AES key back to the new device over the websocket.

After that both machines have the same AES key, so are able to read & write the same data.

storage

This is storage agnostic. You would want to save the identity object to a database or something, which is easy to do because keys are encrypted "at rest". Any device record pairs with a keystore instance on the device.

env variables

We are not using any env variables. If you use an env variable, deploy to partykit like this:

npx partykit deploy --with-vars

There is an env variable, PARTYKIT_TOKEN, on github. This is for deploying partykit automatically on any github push. It's not used by our app.


test

Tests run in node because we are using @ssc-hermes/node-components.

npm test

API

Import functions and types

import { test } from '@nichoth/tapzero'
import { writeKeyToDid, DID } from '@ssc-half-light/util'
import {
    components,
    createCryptoComponent
} from '@ssc-hermes/node-components'
import { Crypto } from '@oddjs/odd'
import { aesEncrypt, aesDecrypt } from
    '@oddjs/odd/components/crypto/implementation/browser'
import { fromString, toString } from 'uint8arrays'
import {
    create, decryptKey, Identity, ALGORITHM, add,
    createDeviceName, encryptTo, CurriedEncrypt,
    decryptMsg
} from '@ssc-half-light/identity'

strings

Convenient helpers that will encode and decode strings with base64pad format.

import { arrayBuffer } from '@ssc-half-light/identity'
const { fromString, toString } = arrayBuffer

create

Create an identity

let identity:Identity
let rootDid:DID
let crypto:Crypto.Implementation
let rootDeviceName:string

test('create an identity', async t => {
    // ...get an odd program somehow
    crypto = program.components.crypto
    rootDid = await writeKeyToDid(crypto)

    identity = await create(crypto, {
        humanName: 'alice',
    })

    const deviceName = await createDeviceName(rootDid)
    rootDeviceName = deviceName
    t.ok(identity, 'should return a new identity')
    t.ok(identity.devices[deviceName].aes,
        'should map the symmetric key, indexed by device name')
})

decryptKey

Decrypt the given encrypted AES key.

async function decryptKey (
    crypto:Crypto.Implementation,
    encryptedKey:string
):Promise<CryptoKey>
const aes = identity.devices[rootDeviceName].aes
const decryptedKey = await decryptKey(crypto, aes)

Use the decrypted key to read and write

import { aesDecrypt, aesEncrypt } from '@ssc-half-light/identity'

test('can use the keys', async t => {
    // test that you can encrypt & decrypt with the symmetric key
    //   saved in identity

    // first decrypt the key
    const aes = identity.devices[rootDeviceName].aes
    const decryptedKey = await decryptKey(crypto, aes)
    t.ok(decryptedKey instanceof CryptoKey, 'decryptKey should return a CryptoKey')

    // now use it to encrypt a string
    const encryptedString = await aesEncrypt(
        fromString('hello'), decryptedKey, ALGORITHM)

    t.ok(encryptedString instanceof Uint8Array,
        'should return a Uint8Array when you encrypt a string')

    // now decrypt the string
    const decrypted = toString(
        await aesDecrypt(encryptedString, decryptedKey, ALGORITHM)
    )

    t.equal(decrypted, 'hello', 'can decrypt the original string')
})

encryptKey

Encrypt a given AES key to a given exchange key. You mostly should not need to use this.

/**
 * Encrypt a given AES key to the given exchange key
 * @param key The symmetric key
 * @param exchangeKey The exchange key to encrypt *to*
 * @returns the encrypted key, encoded as 'base64pad'
 */
export async function encryptKey (
    key:CryptoKey,
    exchangeKey:Uint8Array|CryptoKey
):Promise<string>

add

Add a device to this identity.

We need to pass in the crypto object from the original identity, because we need to decrypt the secret key, then re-encrypt it to the new device:

// decrypt the AES key
const secretKey = await decryptKey(
    crypto,
    id.devices[existingDeviceName].aes
)

We need to call this function from the existing device, because we need to decrypt the AES key. We then re-encrypt the AES key to the public exchange key of the new device. That means we need to get the exchangeKey of the new device somehow.

test('add a device to the identity', async t => {
    const device2Crypto = await createCryptoComponent()
    const newDid = await writeKeyToDid(device2Crypto)
    const exchangeKey = await device2Crypto.keystore.publicExchangeKey()

    // add the device. Returns the ID with the new device added
    // NOTE this takes params from the original keypair -- `crypto`
    //  and also params from the new keypair -- `exchangeKey`
    const id = await add(identity, crypto, newDid, exchangeKey)

    t.ok(id, 'should return a new identity')
    const newDeviceName = await createDeviceName(newDid)
    t.ok(identity.devices[newDeviceName],
        'new identity should have a new device with the expected name')
    t.ok(identity.devices[rootDeviceName],
        'identity should still have the original device')
})

encryptTo

Encrypt a message to the given set of identities. To decrypt this message, use your exchange key to decrypt the AES key, then use the AES key to decrypt the payload.

/**
 * This creates a new AES key each time it is called.
 * 
 * @param crypto odd crypto object
 * @param ids The Identities we are encrypting to
 * @param data The message we want to encrypt
 */
export async function encryptTo (
    creator:Identity,
    ids:Identity[],
    data?:string|Uint8Array
):Promise<EncryptedMessage | CurriedEncrypt>

encryptTo example

// a message from alice to bob
const encryptedMsg = await encryptTo(alice, [bob], 'hello bob')

const alice = await create(alicesCrypto, {
    humanName: 'alice'
})
const bob = await create(bobsCrypto, {
    humanName: 'bob'
})

curried encryptTo

encryptTo can be partially applied by calling without the last argument, the message.

const encryptedGroup = await encryptTo(alice, [
    bob,
    carol
]) as CurriedEncrypt

decryptMsg

Decrypt a message. Takes an encrypted message, and returns the decrypted message body.

async function decryptMsg (
    crypto:Crypto.Implementation,
    encryptedMsg:EncryptedMessage
):Promise<string>

decryptMsg example

const newMsg = await encryptTo(alice, [bob], 'hello bob') as EncryptedMessage
t.ok(newMsg.payload, 'Encrypted message should have payload')

const newDecryptedMsg = await decryptMsg(bobsCrypto, newMsg)

t.equal(newDecryptedMsg, 'hello bob',
    'Bob can decrypt a message encrypted to bob')

group

Create a group of identities that share a single AES key.

This will return a new function that encrypts data with the given key.

This differs from encryptTo, above, because this takes an existing key, instead of creating a new one.

export type Group = {
    groupMembers: Identity[];
    encryptedKeys: Record<string, string>;
    decrypt: (
        crypto:Crypto.Implementation,
        group:Group,
        msg:string|Uint8Array
    ) => Promise<string>;
    (data:string|Uint8Array): Promise<string>
}
/**
 * Create a group with the given AES key.
 *
 * @param creator The identity that is creating this group
 * @param ids An array of group members
 * @param key The AES key for this group
 * @returns {Promise<Group>} Return a function that takes a string of
 * data and returns a string of encrypted data. Has keys `encryptedKeys` and
 * `groupMemebers`. `encryptedKeys` is a map of `deviceName` to the
 * encrypted AES key for this group. `groupMembers` is an array of all
 * the Identities in this group.
 */
export async function group (
    creator:Identity,
    ids:Identity[],
    key:CryptoKey
):Promise<Group>

group example

import { group } from '@ssc-half-light/identity'

// bob and carol are instances of Identity
const myGroup = await group(alice, [bob, carol], key)

group.Decrypt

Decrypt a message that has been encrypted to the group.

async function Decrypt (
    group:Group,
    crypto:Crypto.Implementation,
    msg:string|Uint8Array
):Promise<string>

group.Decrypt example

import { group } from '@ssc-half-light/identity'

const myGroup = await group(alice, [bob, carol], key)
const groupMsg = await myGroup('hello group')
const msg = await group.Decrypt(alicesCrytpo, myGroup, groupMsg)
// => 'hello group'

AddToGroup

Add another identity to a group, and return a new group (not the same instance).

If you pass in a Crypto.Implementation instance, then we will use that to decrypt the key of the given group.

If you pass in an AES CryptoKey, it will be encrypted to the new user. It should be the same AES key that is used by the group.

async function AddToGroup (
    group:Group,
    keyOrCrypto:CryptoKey|Implementation,
    newGroupMember:Identity,
):Promise<Group>

AddToGroup example

import { AddToGroup, create } from '@ssc-half-light/identity'

const fran = await create(_crypto, {
    humanName: 'fran'
})

const newGroup = await AddToGroup(myGroup, alicesCrytpo, fran)

getDeviceName

Create a URL-friendly hash string for a device. This is 32 characters of a hash for a given device's DID. It will always return the same string for the same DID/device.

Pass in a crypto instance or DID string

async function getDeviceName (input:DID|Crypto.Implementation):Promise<string>

getDeviceName example

Pass in a crypto instance

import { getDeviceName } from '@ssc-half-light/identity'

const myDeviceName = getDeviceName(program.components.crypto)
// => '4k4z2xpgpmmssbcasqanlaxoxtpppl54'

Pass in a DID as a string

import { getDeviceName } from '@ssc-half-light/identity'

const deviceName = getDeviceName('did:key:z13V3Sog2Y...')
// => '4k4z2xpgpmmssbcasqanlaxoxtpppl54'
0.2.3

4 months ago

0.2.5

4 months ago

0.2.1

4 months ago

0.2.0

4 months ago

0.1.27

4 months ago

0.1.25

5 months ago

0.1.23

5 months ago

0.1.20

5 months ago

0.1.17

5 months ago

0.1.18

5 months ago

0.1.15

5 months ago

0.1.13

5 months ago

0.1.12

6 months ago

0.1.11

6 months ago

0.1.10

6 months ago

0.1.9

6 months ago

0.1.8

6 months ago

0.1.7

6 months ago

0.1.6

6 months ago

0.1.5

6 months ago

0.1.4

6 months ago

0.1.3

6 months ago

0.1.2

6 months ago

0.1.1

6 months ago

0.1.0

6 months ago

0.0.15

6 months ago

0.0.14

6 months ago

0.0.13

6 months ago

0.0.12

7 months ago

0.0.11

7 months ago

0.0.10

7 months ago

0.0.9

7 months ago

0.0.8

7 months ago