Skip to content

Commit

Permalink
feat: simple mutability API using IPNS (#648)
Browse files Browse the repository at this point in the history
This PR implements w3name as specified in https://github.com/alanshaw/w3name with some changes to the DB schema because it turns out IPNS record newness isn't solely determined by sequence number.

Demo:
<img width="1164" alt="Screenshot 2021-11-17 at 15 27 27" src="https://user-images.githubusercontent.com/152863/142232523-30c117b9-c23c-4cd4-9c33-d426d4dd4a94.png">
  • Loading branch information
Alan Shaw authored Nov 25, 2021
1 parent 774c5ee commit 9c287bb
Show file tree
Hide file tree
Showing 35 changed files with 4,844 additions and 8,513 deletions.
11,345 changes: 2,859 additions & 8,486 deletions package-lock.json

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,33 @@ Delete a given user token.
Get the user account information.
### 🔒 `POST /name/:key`
**❗️Experimental** this API may not work, may change, and may be removed in a future version.
Publish a name record for the given key ID.
Users create a keypair<sup>*</sup> and derive a **Key ID** from the public key that acts as the "name".
<details>
<summary>What is the Key ID?</summary>
<p>The Key ID is the base36 "libp2p-key" encoding of the public key. The public key is protobuf encoded and contains <code>Type</code> and <code>Data</code> properties, see <a href="https://github.com/libp2p/js-libp2p-crypto/blob/c29c1490bbd25722437fdb36f2f0d1a705f35909/src/keys/ed25519-class.js#L25-L30"><code>ed25519-class.js</code> for example</a>.</p>
</details>
The updated IPNS record is signed with the private key and sent in the request body (base 64 encoded). The server validates the record and ensures the sequence number is greater than the sequence number of any cached record.
<sup>*</sup> Currently a Ed25519 2048 bit (min) key.
### 🤲 `GET /name/:key`
**❗️Experimental** this API may not work, may change, and may be removed in a future version.
Resolve the current CID for the given key ID.
Users "resolve" a Key ID to the current _value_ of a _record_. Typically an IPFS path. Keypair owners "publish" IPNS _records_ to create or update the current _value_.
It returns the resolved value AND the full name record (base 64 encoded, for client side verification).
## Setup Sentry
Inside the `/packages/api` folder create a file called `.env.local` with the following content.
Expand Down
4 changes: 4 additions & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.28.0",
"@google-cloud/precise-date": "^2.0.4",
"@ipld/car": "^3.1.4",
"@ipld/dag-cbor": "^6.0.3",
"@ipld/dag-pb": "^2.0.2",
Expand All @@ -52,10 +53,13 @@
"@nftstorage/ipfs-cluster": "^3.3.1",
"@web3-storage/db": "^3.0.0",
"@web3-storage/multipart-parser": "^1.0.0",
"cborg": "^1.5.3",
"ipfs-car": "^0.5.8",
"itty-router": "^2.3.10",
"multiformats": "^9.0.4",
"p-retry": "^4.6.1",
"protobufjs": "^6.11.2",
"tweetnacl": "^1.0.3",
"uint8arrays": "^3.0.0"
},
"bundlesize": [
Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
READ_WRITE
} from './maintenance.js'
import { notFound } from './utils/json-response.js'
import { nameGet, namePost } from './name.js'

const router = Router()
router.options('*', corsOptions)
Expand Down Expand Up @@ -43,6 +44,9 @@ router.put('/car/:cid', mode['📝'](auth['🔒'](carPut)))
router.post('/upload', mode['📝'](auth['🔒'](uploadPost)))
router.get('/user/uploads', mode['👀'](auth['🔒'](userUploadsGet)))

router.get('/name/:key', mode['👀'](auth['🤲'](nameGet)))
router.post('/name/:key', mode['📝'](auth['🔒'](namePost)))

router.delete('/user/uploads/:cid', mode['📝'](auth['👮'](userUploadsDelete)))
router.post('/user/uploads/:cid/rename', mode['📝'](auth['👮'](userUploadsRename)))
router.get('/user/tokens', mode['👀'](auth['👮'](userTokensGet)))
Expand Down
69 changes: 69 additions & 0 deletions packages/api/src/name.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
import * as ipns from './utils/ipns/index.js'
import * as Digest from 'multiformats/hashes/digest'
import { base36 } from 'multiformats/bases/base36'
import { CID } from 'multiformats/cid'
import * as ed25519 from './utils/crypto/ed25519.js'
import { PreciseDate } from '@google-cloud/precise-date'
import { HTTPError } from './errors.js'
import { JSONResponse } from './utils/json-response.js'

const libp2pKeyCode = 0x72

/**
* @param {Request} request
* @param {import('./env').Env} env
*/
export async function nameGet (request, env) {
const { params: { key } } = request
const { code } = CID.parse(key, base36)
if (code !== libp2pKeyCode) {
throw new HTTPError(`invalid key, expected: ${libp2pKeyCode} codec code but got: ${code}`, 400)
}

const rawRecord = await env.db.resolveNameRecord(key)
if (!rawRecord) {
throw new HTTPError(`record not found for key: ${key}. Only keys published using the Web3.Storage API may be resolved here.`, 404)
}

const { value } = ipns.unmarshal(uint8ArrayFromString(rawRecord, 'base64pad'))

return new JSONResponse({
value: uint8ArrayToString(value),
record: rawRecord
})
}

/**
* @param {Request} request
* @param {import('./env').Env} env
*/
export async function namePost (request, env) {
const { params: { key } } = request
const keyCid = CID.parse(key, base36)

if (keyCid.code !== libp2pKeyCode) {
throw new HTTPError(`invalid key code: ${keyCid.code}`, 400)
}

const record = await request.text()
const entry = ipns.unmarshal(uint8ArrayFromString(record, 'base64pad'))
const pubKey = ed25519.unmarshalPublicKey(Digest.decode(keyCid.multihash.bytes).bytes)

if (entry.pubKey && !ed25519.unmarshalPublicKey(entry.pubKey).equals(pubKey)) {
throw new HTTPError('embedded public key mismatch', 400)
}

await ipns.validate(pubKey, entry)

await env.db.publishNameRecord(
key,
record,
Boolean(entry.signatureV2),
entry.sequence,
new PreciseDate(uint8ArrayToString(entry.validity)).getFullTime()
)

return new JSONResponse({ id: key }, { status: 202 })
}
97 changes: 97 additions & 0 deletions packages/api/src/utils/crypto/ed25519.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/* eslint-env browser */
import { sha256 } from 'multiformats/hashes/sha2'
import { equals as uint8ArrayEquals } from 'uint8arrays/equals'
import { PublicKey, KeyType } from './pb/keys.js'
import { errCode } from '../err-code.js'

const PUBLIC_KEY_BYTE_LENGTH = 32

const ERROR_NON_ED25519_PUBLIC_KEY = 'ERROR_NON_ED25519_PUBLIC_KEY'
const ERROR_INVALID_KEY_LENGTH = 'ERROR_INVALID_KEY_LENGTH'

/**
* NODE-ED25519 exists only in the CloudFlare workers runtime API
* see: https://developers.cloudflare.com/workers/runtime-apis/web-crypto#footnote%201
*/
const CLOUDFLARE_ED25519 = {
name: 'NODE-ED25519',
namedCurve: 'NODE-ED25519'
}

/**
* @param {Uint8Array} key Public key to verify with.
* @param {Uint8Array} sig Signature used to sign the data.
* @param {Uint8Array} data Data to verify.
*/
async function verify (key, sig, data) {
try {
const cryptoKey = await crypto.subtle.importKey('raw', key, CLOUDFLARE_ED25519, false, ['verify'])
return crypto.subtle.verify(CLOUDFLARE_ED25519, cryptoKey, sig, data)
} catch (err) {
if (err instanceof Error && err.name === 'NotSupportedError') {
console.warn('using tweetnacl for ed25519 - you should not see this message when running in the CloudFlare worker runtime')
const { default: nacl } = await import('tweetnacl')
return nacl.sign.detached.verify(data, sig, key)
}
throw err
}
}

/**
* @param {Uint8Array} buf
*/
export function unmarshalPublicKey (buf) {
const decoded = PublicKey.decode(buf)
const data = decoded.Data
if (decoded.Type !== KeyType.Ed25519) {
throw errCode(new Error('invalid public key type'), ERROR_NON_ED25519_PUBLIC_KEY)
}
return unmarshalEd25519PublicKey(data)
}

class Ed25519PublicKey {
constructor (key) {
this._key = ensureKey(key, PUBLIC_KEY_BYTE_LENGTH)
}

async verify (data, sig) {
return verify(this._key, sig, data)
}

marshal () {
return this._key
}

get bytes () {
return PublicKey.encode({
Type: KeyType.Ed25519,
Data: this.marshal()
}).finish()
}

equals (key) {
return uint8ArrayEquals(this.bytes, key.bytes)
}

async hash () {
const { bytes } = await sha256.digest(this.bytes)
return bytes
}
}

function unmarshalEd25519PublicKey (bytes) {
bytes = ensureKey(bytes, PUBLIC_KEY_BYTE_LENGTH)
return new Ed25519PublicKey(bytes)
}

/**
* @param {Uint8Array} key
* @param {number} length
*/
function ensureKey (key, length) {
key = Uint8Array.from(key || [])
if (key.length !== length) {
throw errCode(new Error(`key must be a Uint8Array of length ${length}, got ${key.length}`), ERROR_INVALID_KEY_LENGTH)
}
return key
}
Loading

0 comments on commit 9c287bb

Please sign in to comment.