14

I'm trying to create a signature for a privately stored file in Google Cloud Storage; so that I can distribute a time-limited link.

Currently doing this and it makes a signature that's too short ... where am I going wrong?

var crypto = require("crypto");

var ttl = new Date().getTime() + 3600;
var id = 'the_target_file.txt';
var bucketName = 'bucket_name';
var POLICY_JSON = "GET\n" + "\n" + "\n" + ttl + "\n" + '/' + bucketName + '/' + id;

// stringify and encode the policy
var stringPolicy = JSON.stringify(POLICY_JSON);
var base64Policy = Buffer(stringPolicy, "utf-8").toString("base64");

// sign the base64 encoded policy
var privateKey = "MY_PRIVATE_KEY";
var sha256 = crypto.createHmac("sha256", privateKey);
var signature = sha256.update(new Buffer(base64Policy, "utf-8")).digest("base64");

console.log ( signature );
stukennedy
  • 1,088
  • 1
  • 9
  • 25

5 Answers5

22

There is an API/module for getting signed URLs now.

module: https://www.npmjs.com/package/@google-cloud/storage
API docs: https://googleapis.dev/nodejs/storage/latest/File.html#getSignedUrl

Example

var storage = require('@google-cloud/storage')();
var myBucket = storage.bucket('my-bucket');

var file = myBucket.file('my-file');

//-
// Generate a URL that allows temporary access to download your file.
//-
var request = require('request');

var config = {
  action: 'read',
  expires: '03-17-2025'  // this could also include time (MM-DD-YYYYTHH:MM:SSZ)
};

file.getSignedUrl(config, function(err, url) {
  if (err) {
    console.error(err);
    return;
  }

  // The file is now available to read from this URL.
  request(url, function(err, resp) {
    // resp.statusCode = 200
  });
});
Philipp Kyeck
  • 18,402
  • 15
  • 86
  • 123
  • 1
    This seems to only create the link for the bucket access, but not for the CDN. Adding the cname option for the CDN still gives no permission to the service account. – htafoya Sep 20 '18 at 07:28
  • This looks like a really handy abstraction of the previously convoluted requirements. What is being used to sign the URLs in this case? – Brendan Moore May 06 '19 at 12:42
  • Worth noting that you can do time for the expiry as well in the format MM-DD-YYYYTHH:MM:SSZ - fractional seconds seem to trip this up so make sure your string doesn't include a decimal and/or fractional parts of a second before the 'Z' – GoForth Aug 25 '20 at 20:08
  • Really MM-DD-YYYY and not YYYY-MM-DD like ISO dates normaly go? – max Oct 20 '21 at 20:59
  • @max yup, still correct: https://googleapis.dev/nodejs/storage/latest/File.html#getSignedUrl-examples but you can also use a timestamp `A timestamp when this link will expire. Any value given is passed to new Date(). Note: 'v4' supports maximum duration of 7 days (604800 seconds) from now.` – Philipp Kyeck Oct 21 '21 at 10:23
21

Realised what I was doing wrong ... I was hashing the policy string instead of signing it. The below code now gives me the correct output.

var crypto = require("crypto");
var fs = require("fs");

var expiry = new Date().getTime() + 3600;
var key = 'the_target_file';
var bucketName = 'bucket_name';
var accessId = 'my_access_id';
var stringPolicy = "GET\n" + "\n" + "\n" + expiry + "\n" + '/' + bucketName + '/' + key; 
var privateKey = fs.readFileSync("gcs.pem","utf8");
var signature = encodeURIComponent(crypto.createSign('sha256').update(stringPolicy).sign(privateKey,"base64"));   
var signedUrl = "https://" + bucketName + ".commondatastorage.googleapis.com/" + key +"?GoogleAccessId=" + accessId + "&Expires=" + expiry + "&Signature=" + signature;

console.log(signedUrl);

For completeness ... here is a PHP version that does the same thing, which I used to check my results

$expiry = time() + 3600;
$key = 'the_target_file';
$bucketName = 'bucket_name';
$accessId = 'my_access_id';
$stringPolicy = "GET\n\n\n".$expiry."\n/".$bucketName."/".$key;
$fp = fopen('gcs.pem', 'r');
$priv_key = fread($fp, 8192);
fclose($fp);
$pkeyid = openssl_get_privatekey($priv_key,"password"); 
if (openssl_sign( $stringPolicy, $signature, $pkeyid, 'sha256' )) {
    $signature = urlencode( base64_encode( $signature ) );    
    echo 'https://'.$bucketName.'.commondatastorage.googleapis.com/'.
              $key.'?GoogleAccessId='.$accessId.'&Expires='.$expiry.'&Signature='.$signature;
}
musicformellons
  • 12,283
  • 4
  • 51
  • 86
stukennedy
  • 1,088
  • 1
  • 9
  • 25
3

Assuming this question is to sign the CDN url backed by google bucket backend, here what works for me (code above did not work for me).

Opts and signing function calling:

const signUrlOptions = {
  expires: '' + new Date().getTime() + 3600, // one hour
  keyName: '_SIGNING_KEY_NAME_', // URL signing key name (the one one you created in the CDN backend bucket)
  keyBase64: '_SIGNING_KEY_BASE64_', // the URL signing key base64 content (base64-encoded, 128-bit value, ~24 characters)
  baseUrl: '_CDN_BASE_URL_' // your base CDN URL (can be IP http://123.... when dev env or https://cdn_dns_name or https dns name)
}

const signedUrl = signCdnUrl('demo.png', signedUrlOptions);

signing function:

import { createHmac } from 'crypto';

const BASE64_REPLACE = { '+': '-', '/': '_', '=': '' };

export function signCdnUrl(fileName, opts) {
  // URL to sign
  const urlToSign = `${opts.baseUrl}/${fileName}?Expires=${opts.expires}&KeyName=${opts.keyName}`;

  // Compute signature
  const keyBuffer = Buffer.from(opts.keyBase64, 'base64');
  let signature = createHmac('sha1', keyBuffer).update(urlToSign).digest('base64');
  signature = signature.replace(/[+/=]/g, c => (<any>BASE64_REPLACE)[c]); // might be a better way

  // Add signature to urlToSign and return signedUrl
  return urlToSign + `&Signature=${signature}`;
}

Hope this helps. Somehow google cloud doc does not have a nodejs example and the file.getSignedUrl() add confusion to the mix as it does not seem to be related to CDN URL signing.

Note:

Note: Probably want to move base64 -> buffer work to the caller as opts.keyBuffer

Jeremy Chone
  • 3,079
  • 1
  • 27
  • 28
2

If nodejs @google-cloud/storage library is already part of your project then best way is to use it. Below code is by google storage sdk docs for nodejs Link here

npm install @google-cloud/storage

function main(bucketName = 'you_bucket_name', filename = 'your_file_path_without_bucket_name') {
    const {Storage} = require('@google-cloud/storage');

    // Creates a client (Parameters not required if you are already in GCP environment)
    const storage = new Storage({
        projectId: 'your_project_id',
        keyFilename: './json_key_path_for_some_service_account.json'
    });

    async function generateV4ReadSignedUrl() {
        // These options will allow temporary read access to the file
        const options = {
            version: 'v4',
            action: 'read',
            expires: Date.now() + 15 * 60 * 1000, // 15 minutes
        };

        // Get a v4 signed URL for reading the file
        const [url] = await storage
            .bucket(bucketName)
            .file(filename)
            .getSignedUrl(options);

        console.log('Generated GET signed URL:');
        console.log(url);
        console.log('You can use this URL with any user agent, for example:');
        console.log(`curl '${url}'`);
    }

    generateV4ReadSignedUrl().catch(console.error);
    // [END storage_generate_signed_url_v4]
}
main(...process.argv.slice(2));
Ahsan.Amin
  • 736
  • 6
  • 14
0

V4 signed URL In pure js, no google api libs. Pay attention to all the new lines in canonical request and slashes in paths.

const crypto = require('crypto');

const bucket_id = 'blabla-414e5'
const object_name = 'abc/def';

const privateKey = "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkifZ6...your private key.. PUWvzK3wRRQDzY5A/ccjEXRiSZgM0/autPZlOsVVlMTG\n3dEtbIpYRz7y+yvBH4HIYA==\n-----END PRIVATE KEY-----\n"


const object_uri = `${bucket_id}.appspot.com/${object_name}`
const request_timestamp  = new Date().toISOString().replaceAll('-', '').replaceAll(':', '').substring(0, 15) + 'Z'
//const request_timestamp = '20230614T150520Z'
const datestamp = request_timestamp.substring(0, 8)  
const service_account = bucket_id + '@appspot.gserviceaccount.com'
const credential_scope = `${datestamp}/auto/storage/goog4_request`
const credential = `${service_account}/${credential_scope}`

const expiration = 3600

const canonical_query = [
'x-goog-algorithm=GOOG4-RSA-SHA256',
`x-goog-credential=${encodeURIComponent(credential)}`,
`x-goog-date=${encodeURIComponent(request_timestamp)}`,
`x-goog-expires=${encodeURIComponent(expiration)}`,
'x-goog-signedheaders=host'].join('&')

const canonical_request =
`PUT
/${object_uri}
${canonical_query}
host:storage.googleapis.com

host
UNSIGNED-PAYLOAD`

const canonical_request_hash = crypto.createHash('sha256').update(canonical_request).digest('hex');

const sign_string = 
`GOOG4-RSA-SHA256
${request_timestamp}
${credential_scope}
${canonical_request_hash}`

const signature = crypto.createSign('sha256').update(sign_string).sign(privateKey, 'hex');

const signedUrl = `https://storage.googleapis.com/${object_uri}?${canonical_query}&x-goog-signature=${signature}`;

console.log(signedUrl)
Sharas
  • 860
  • 8
  • 18